From ce32ae313406e0f5cff6787b35b25e313c64bdf1 Mon Sep 17 00:00:00 2001 From: StillHammer Date: Thu, 4 Dec 2025 20:57:51 +0800 Subject: [PATCH] Migration Gitea --- .claude/settings.local.json | 18 +- CLAUDE.md | 256 ++-- docs/API.md | 1122 ++++++++-------- nul | 1 + public/app.js | 2258 ++++++++++++++++---------------- public/index.html | 1154 ++++++++--------- public/style.css | 1432 ++++++++++----------- src/server.js | 2274 ++++++++++++++++----------------- src/services/conversion.js | 290 ++--- src/services/summarize.js | 390 +++--- src/services/transcription.js | 356 +++--- src/services/translation.js | 542 ++++---- src/services/youtube.js | 582 ++++----- start-server.bat | 122 +- start-server.sh | 116 +- 15 files changed, 5457 insertions(+), 5456 deletions(-) create mode 100644 nul diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 919dca9..025a3c6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,9 +1,9 @@ -{ - "permissions": { - "allow": [ - "Bash(npm run server:*)" - ], - "deny": [], - "ask": [] - } -} +{ + "permissions": { + "allow": [ + "Bash(npm run server:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index d6c04b3..3b00787 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,128 +1,128 @@ -# Video to MP3 Transcriptor - Instructions pour Claude - -## À propos du projet -Ce projet est une API Node.js/Express pour télécharger des vidéos YouTube en MP3, les transcrire, les traduire et les résumer. - -## Documentation - -### Documentation API -La documentation complète de l'API se trouve dans **`docs/API.md`**. - -**IMPORTANT** : Cette documentation doit TOUJOURS être maintenue à jour. Chaque fois qu'un endpoint est modifié, ajouté ou supprimé, la documentation doit être mise à jour en conséquence. - -### Responsabilités de maintenance de la documentation - -Quand tu modifies le code, tu DOIS mettre à jour `docs/API.md` si : -- Un nouvel endpoint est ajouté -- Un endpoint existant est modifié (paramètres, réponses, etc.) -- Un endpoint est supprimé -- Les modèles par défaut changent -- De nouveaux paramètres sont ajoutés -- Le format des réponses change - -## Structure du projet - -``` -videotoMP3Transcriptor/ -├── docs/ -│ └── API.md # Documentation complète de l'API -├── src/ -│ ├── server.js # Serveur Express et routes API -│ ├── services/ -│ │ ├── youtube.js # Téléchargement YouTube -│ │ ├── transcription.js # Transcription OpenAI -│ │ ├── translation.js # Traduction GPT -│ │ └── summarize.js # Résumé GPT-5.1 -│ └── cli.js # Interface en ligne de commande -├── public/ # Interface web (si présente) -├── output/ # Répertoire de sortie par défaut -├── .env # Variables d'environnement -└── package.json - -``` - -## Configuration - -### Port du serveur -- Port par défaut : **8888** -- Configurable via `process.env.PORT` dans `.env` - -### Modèles par défaut -- **Transcription** : `gpt-4o-mini-transcribe` -- **Résumé** : `gpt-5.1` -- **Traduction** : `gpt-4o-mini` (hardcodé) - -### Variables d'environnement requises -```env -OPENAI_API_KEY=sk-... -PORT=8888 # optionnel -OUTPUT_DIR=./output # optionnel -``` - -## Commandes importantes - -```bash -# Lancer le serveur -npm run server - -# Lancer le CLI -npm run cli - -# Installer les dépendances -npm install -``` - -## Points d'attention - -### Paramètres outputPath -Tous les endpoints supportent maintenant un paramètre `outputPath` optionnel pour spécifier un répertoire de sortie personnalisé. Si non spécifié, le répertoire par défaut `OUTPUT_DIR` est utilisé. - -### Modèles de transcription disponibles -- `gpt-4o-mini-transcribe` (par défaut) - Rapide et économique -- `gpt-4o-transcribe` - Qualité supérieure -- `whisper-1` - Modèle original Whisper (supporte plus de formats) - -### Formats de sortie -- **Transcription** : txt, json, srt, vtt (selon le modèle) -- **Traduction** : txt -- **Résumé** : txt - -## Règles de développement - -1. **Documentation d'abord** : Avant de modifier un endpoint, vérifie `docs/API.md` -2. **Après modification** : Mets à jour immédiatement `docs/API.md` -3. **Tests** : Redémarre le serveur après chaque modification -4. **Cohérence** : Garde la même structure de réponse pour tous les endpoints similaires - -## Architecture des endpoints - -### Endpoints streaming (SSE) -- `/download-stream` -- `/process-stream` -- `/summarize-stream` - -Ces endpoints utilisent Server-Sent Events pour envoyer des mises à jour de progression en temps réel. - -### Endpoints non-streaming -- `/download` -- `/process` -- Tous les endpoints POST avec upload de fichiers - -Ces endpoints retournent une réponse unique une fois le traitement terminé. - -## Maintenance - -Lors de l'ajout de nouvelles fonctionnalités : -1. Implémente la fonctionnalité dans le service approprié (`src/services/`) -2. Ajoute les routes dans `src/server.js` -3. **Mets à jour `docs/API.md` IMMÉDIATEMENT** -4. Teste l'endpoint avec curl ou Postman -5. Vérifie que la documentation est claire et complète - -## Notes importantes - -- Le serveur doit toujours être sur le port **8888** -- Les clés API OpenAI sont requises pour transcription/traduction/résumé -- Le répertoire `output/` est créé automatiquement si inexistant -- Les fichiers uploadés sont stockés dans `OUTPUT_DIR` -- Les vidéos YouTube sont téléchargées en MP3 automatiquement +# Video to MP3 Transcriptor - Instructions pour Claude + +## À propos du projet +Ce projet est une API Node.js/Express pour télécharger des vidéos YouTube en MP3, les transcrire, les traduire et les résumer. + +## Documentation + +### Documentation API +La documentation complète de l'API se trouve dans **`docs/API.md`**. + +**IMPORTANT** : Cette documentation doit TOUJOURS être maintenue à jour. Chaque fois qu'un endpoint est modifié, ajouté ou supprimé, la documentation doit être mise à jour en conséquence. + +### Responsabilités de maintenance de la documentation + +Quand tu modifies le code, tu DOIS mettre à jour `docs/API.md` si : +- Un nouvel endpoint est ajouté +- Un endpoint existant est modifié (paramètres, réponses, etc.) +- Un endpoint est supprimé +- Les modèles par défaut changent +- De nouveaux paramètres sont ajoutés +- Le format des réponses change + +## Structure du projet + +``` +videotoMP3Transcriptor/ +├── docs/ +│ └── API.md # Documentation complète de l'API +├── src/ +│ ├── server.js # Serveur Express et routes API +│ ├── services/ +│ │ ├── youtube.js # Téléchargement YouTube +│ │ ├── transcription.js # Transcription OpenAI +│ │ ├── translation.js # Traduction GPT +│ │ └── summarize.js # Résumé GPT-5.1 +│ └── cli.js # Interface en ligne de commande +├── public/ # Interface web (si présente) +├── output/ # Répertoire de sortie par défaut +├── .env # Variables d'environnement +└── package.json + +``` + +## Configuration + +### Port du serveur +- Port par défaut : **8888** +- Configurable via `process.env.PORT` dans `.env` + +### Modèles par défaut +- **Transcription** : `gpt-4o-mini-transcribe` +- **Résumé** : `gpt-5.1` +- **Traduction** : `gpt-4o-mini` (hardcodé) + +### Variables d'environnement requises +```env +OPENAI_API_KEY=sk-... +PORT=8888 # optionnel +OUTPUT_DIR=./output # optionnel +``` + +## Commandes importantes + +```bash +# Lancer le serveur +npm run server + +# Lancer le CLI +npm run cli + +# Installer les dépendances +npm install +``` + +## Points d'attention + +### Paramètres outputPath +Tous les endpoints supportent maintenant un paramètre `outputPath` optionnel pour spécifier un répertoire de sortie personnalisé. Si non spécifié, le répertoire par défaut `OUTPUT_DIR` est utilisé. + +### Modèles de transcription disponibles +- `gpt-4o-mini-transcribe` (par défaut) - Rapide et économique +- `gpt-4o-transcribe` - Qualité supérieure +- `whisper-1` - Modèle original Whisper (supporte plus de formats) + +### Formats de sortie +- **Transcription** : txt, json, srt, vtt (selon le modèle) +- **Traduction** : txt +- **Résumé** : txt + +## Règles de développement + +1. **Documentation d'abord** : Avant de modifier un endpoint, vérifie `docs/API.md` +2. **Après modification** : Mets à jour immédiatement `docs/API.md` +3. **Tests** : Redémarre le serveur après chaque modification +4. **Cohérence** : Garde la même structure de réponse pour tous les endpoints similaires + +## Architecture des endpoints + +### Endpoints streaming (SSE) +- `/download-stream` +- `/process-stream` +- `/summarize-stream` + +Ces endpoints utilisent Server-Sent Events pour envoyer des mises à jour de progression en temps réel. + +### Endpoints non-streaming +- `/download` +- `/process` +- Tous les endpoints POST avec upload de fichiers + +Ces endpoints retournent une réponse unique une fois le traitement terminé. + +## Maintenance + +Lors de l'ajout de nouvelles fonctionnalités : +1. Implémente la fonctionnalité dans le service approprié (`src/services/`) +2. Ajoute les routes dans `src/server.js` +3. **Mets à jour `docs/API.md` IMMÉDIATEMENT** +4. Teste l'endpoint avec curl ou Postman +5. Vérifie que la documentation est claire et complète + +## Notes importantes + +- Le serveur doit toujours être sur le port **8888** +- Les clés API OpenAI sont requises pour transcription/traduction/résumé +- Le répertoire `output/` est créé automatiquement si inexistant +- Les fichiers uploadés sont stockés dans `OUTPUT_DIR` +- Les vidéos YouTube sont téléchargées en MP3 automatiquement diff --git a/docs/API.md b/docs/API.md index 7b02be2..c54bd38 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,561 +1,561 @@ -# API Documentation - Video to MP3 Transcriptor - -## Base URL -``` -http://localhost:8888 -``` - -## Table of Contents -- [Health & Info](#health--info) -- [Download Endpoints](#download-endpoints) -- [Transcription Endpoints](#transcription-endpoints) -- [Translation Endpoints](#translation-endpoints) -- [Summarization Endpoints](#summarization-endpoints) -- [File Management](#file-management) - ---- - -## Health & Info - -### GET /health -Health check endpoint. - -**Response:** -```json -{ - "status": "ok", - "timestamp": "2025-11-28T12:00:00.000Z" -} -``` - -### GET /api -Get API information and available endpoints. - -**Response:** -```json -{ - "name": "Video to MP3 Transcriptor API", - "version": "1.0.0", - "endpoints": { ... } -} -``` - -### GET /info -Get information about a YouTube video or playlist. - -**Query Parameters:** -- `url` (required): YouTube URL - -**Example:** -```bash -curl "http://localhost:8888/info?url=https://www.youtube.com/watch?v=VIDEO_ID" -``` - -**Response:** -```json -{ - "success": true, - "title": "Video Title", - "type": "video", - "duration": 300, - "channel": "Channel Name", - "videoCount": 1 -} -``` - ---- - -## Download Endpoints - -### GET /download-stream -Download YouTube video(s) to MP3 with Server-Sent Events (SSE) progress updates. - -**Query Parameters:** -- `url` (required): YouTube URL -- `outputPath` (optional): Custom output directory path - -**Example:** -```bash -curl "http://localhost:8888/download-stream?url=https://www.youtube.com/watch?v=VIDEO_ID" -``` - -**SSE Events:** -- `info`: Video/playlist information -- `progress`: Download progress updates -- `video-complete`: Individual video completion -- `complete`: All downloads complete -- `error`: Error occurred - -### POST /download -Download YouTube video(s) to MP3 (non-streaming). - -**Body Parameters:** -```json -{ - "url": "https://www.youtube.com/watch?v=VIDEO_ID", - "outputPath": "./custom/path" // optional -} -``` - -**Example:** -```bash -curl -X POST http://localhost:8888/download \ - -H "Content-Type: application/json" \ - -d '{"url":"https://www.youtube.com/watch?v=VIDEO_ID"}' -``` - -**Response:** -```json -{ - "success": true, - "playlistTitle": null, - "totalVideos": 1, - "successCount": 1, - "failCount": 0, - "videos": [ - { - "success": true, - "title": "Video Title", - "filePath": "./output/video.mp3", - "fileUrl": "/files/video.mp3" - } - ] -} -``` - ---- - -## Transcription Endpoints - -### POST /transcribe -Transcribe an existing audio file. - -**Body Parameters:** -```json -{ - "filePath": "./output/audio.mp3", - "language": "en", // optional (auto-detect if not specified) - "format": "txt", // optional: txt, json, srt, vtt - "model": "gpt-4o-mini-transcribe", // optional: gpt-4o-mini-transcribe (default), gpt-4o-transcribe, whisper-1 - "outputPath": "./custom/path" // optional -} -``` - -**Available Models:** -- `gpt-4o-mini-transcribe` (default) - Fast and cost-effective -- `gpt-4o-transcribe` - Higher quality -- `whisper-1` - Original Whisper model (supports more formats) - -**Example:** -```bash -curl -X POST http://localhost:8888/transcribe \ - -H "Content-Type: application/json" \ - -d '{ - "filePath": "./output/audio.mp3", - "language": "en", - "model": "gpt-4o-mini-transcribe" - }' -``` - -**Response:** -```json -{ - "success": true, - "filePath": "./output/audio.mp3", - "transcriptionPath": "./output/audio.txt", - "transcriptionUrl": "/files/audio.txt", - "text": "Transcribed text content..." -} -``` - -### POST /upload-transcribe -Upload and transcribe audio files. - -**Form Data:** -- `files`: Audio file(s) (multiple files supported, max 50) -- `language`: Language code (optional) -- `model`: Transcription model (optional, default: gpt-4o-mini-transcribe) -- `outputPath`: Custom output directory (optional) - -**Example:** -```bash -curl -X POST http://localhost:8888/upload-transcribe \ - -F "files=@audio1.mp3" \ - -F "files=@audio2.mp3" \ - -F "language=en" \ - -F "model=gpt-4o-mini-transcribe" -``` - -**Response:** -```json -{ - "success": true, - "totalFiles": 2, - "successCount": 2, - "failCount": 0, - "results": [ - { - "success": true, - "fileName": "audio1.mp3", - "transcriptionPath": "./output/audio1.txt", - "transcriptionUrl": "/files/audio1.txt", - "text": "Transcription..." - } - ] -} -``` - -### GET /process-stream -Download + Transcribe with SSE progress updates. - -**Query Parameters:** -- `url` (required): YouTube URL -- `language` (optional): Language code -- `model` (optional): Transcription model (default: gpt-4o-mini-transcribe) -- `outputPath` (optional): Custom output directory - -**Example:** -```bash -curl "http://localhost:8888/process-stream?url=https://www.youtube.com/watch?v=VIDEO_ID&language=en&model=gpt-4o-mini-transcribe" -``` - -**SSE Events:** -- `info`: Video information -- `progress`: Progress updates (downloading or transcribing) -- `video-complete`: Download complete -- `transcribe-complete`: Transcription complete -- `complete`: All operations complete -- `error`: Error occurred - -### POST /process -Download + Transcribe (non-streaming). - -**Body Parameters:** -```json -{ - "url": "https://www.youtube.com/watch?v=VIDEO_ID", - "language": "en", // optional - "format": "txt", // optional - "model": "gpt-4o-mini-transcribe", // optional - "outputPath": "./custom/path" // optional -} -``` - -**Example:** -```bash -curl -X POST http://localhost:8888/process \ - -H "Content-Type: application/json" \ - -d '{ - "url": "https://www.youtube.com/watch?v=VIDEO_ID", - "language": "en", - "model": "gpt-4o-mini-transcribe" - }' -``` - -**Response:** -```json -{ - "success": true, - "playlistTitle": null, - "totalVideos": 1, - "downloadedCount": 1, - "transcribedCount": 1, - "results": [ - { - "title": "Video Title", - "downloadSuccess": true, - "audioPath": "./output/video.mp3", - "audioUrl": "/files/video.mp3", - "transcriptionSuccess": true, - "transcriptionPath": "./output/video.txt", - "transcriptionUrl": "/files/video.txt", - "text": "Transcription..." - } - ] -} -``` - ---- - -## Translation Endpoints - -### GET /languages -Get available translation languages. - -**Response:** -```json -{ - "languages": { - "en": "English", - "fr": "French", - "es": "Spanish", - "de": "German", - "zh": "Chinese", - "ja": "Japanese", - ... - } -} -``` - -### POST /translate -Translate text. - -**Body Parameters:** -```json -{ - "text": "Text to translate", - "targetLang": "fr", // required: target language code - "sourceLang": "en" // optional: source language (auto-detect if not specified) -} -``` - -**Example:** -```bash -curl -X POST http://localhost:8888/translate \ - -H "Content-Type: application/json" \ - -d '{ - "text": "Hello, how are you?", - "targetLang": "fr" - }' -``` - -**Response:** -```json -{ - "success": true, - "originalText": "Hello, how are you?", - "translatedText": "Bonjour, comment allez-vous ?", - "targetLanguage": "French", - "sourceLanguage": "auto-detected", - "chunks": 1 -} -``` - -### POST /translate-file -Translate uploaded text files. - -**Form Data:** -- `files`: Text file(s) (.txt, multiple files supported, max 50) -- `targetLang`: Target language code (required) -- `sourceLang`: Source language code (optional) -- `outputPath`: Custom output directory (optional) - -**Example:** -```bash -curl -X POST http://localhost:8888/translate-file \ - -F "files=@document.txt" \ - -F "targetLang=fr" \ - -F "sourceLang=en" -``` - -**Response:** -```json -{ - "success": true, - "totalFiles": 1, - "successCount": 1, - "failCount": 0, - "results": [ - { - "success": true, - "fileName": "document.txt", - "translationPath": "./output/document_fr.txt", - "translationUrl": "/files/document_fr.txt", - "translatedText": "Translated content..." - } - ] -} -``` - ---- - -## Summarization Endpoints - -### GET /summary-styles -Get available summary styles. - -**Response:** -```json -{ - "styles": { - "concise": "A brief summary capturing main points", - "detailed": "A comprehensive summary with nuances", - "bullet": "Key points as bullet points" - } -} -``` - -### POST /summarize -Summarize text using GPT-5.1. - -**Body Parameters:** -```json -{ - "text": "Long text to summarize...", - "style": "concise", // optional: concise (default), detailed, bullet - "language": "same", // optional: 'same' (default) or language code - "model": "gpt-5.1" // optional: default is gpt-5.1 -} -``` - -**Example:** -```bash -curl -X POST http://localhost:8888/summarize \ - -H "Content-Type: application/json" \ - -d '{ - "text": "Long article content...", - "style": "bullet", - "language": "same" - }' -``` - -**Response:** -```json -{ - "success": true, - "summary": "Summary content...", - "model": "gpt-5.1", - "style": "bullet", - "inputLength": 5000, - "chunks": 1 -} -``` - -### POST /summarize-file -Summarize uploaded text files using GPT-5.1. - -**Form Data:** -- `files`: Text file(s) (.txt, multiple files supported, max 50) -- `style`: Summary style (optional, default: concise) -- `language`: Output language (optional, default: same) -- `model`: AI model (optional, default: gpt-5.1) -- `outputPath`: Custom output directory (optional) - -**Example:** -```bash -curl -X POST http://localhost:8888/summarize-file \ - -F "files=@article.txt" \ - -F "style=detailed" \ - -F "language=same" -``` - -**Response:** -```json -{ - "success": true, - "totalFiles": 1, - "successCount": 1, - "failCount": 0, - "results": [ - { - "success": true, - "fileName": "article.txt", - "summaryPath": "./output/article_summary.txt", - "summaryUrl": "/files/article_summary.txt", - "summary": "Summary content...", - "model": "gpt-5.1", - "chunks": 1 - } - ] -} -``` - -### GET /summarize-stream -Full pipeline: Download -> Transcribe -> Summarize with SSE progress. - -**Query Parameters:** -- `url` (required): YouTube URL -- `style` (optional): Summary style (default: concise) -- `language` (optional): Output language (default: same) -- `model` (optional): Transcription model (default: gpt-4o-mini-transcribe) -- `outputPath` (optional): Custom output directory - -**Example:** -```bash -curl "http://localhost:8888/summarize-stream?url=https://www.youtube.com/watch?v=VIDEO_ID&style=bullet&model=gpt-4o-mini-transcribe" -``` - -**SSE Events:** -- `info`: Video information -- `progress`: Progress updates (downloading, transcribing, or summarizing) -- `video-complete`: Download complete -- `transcribe-complete`: Transcription complete -- `summarize-complete`: Summary complete -- `complete`: All operations complete -- `error`: Error occurred - ---- - -## File Management - -### GET /files-list -List all downloaded/generated files. - -**Example:** -```bash -curl http://localhost:8888/files-list -``` - -**Response:** -```json -{ - "files": [ - { - "name": "video.mp3", - "url": "/files/video.mp3", - "path": "./output/video.mp3" - }, - { - "name": "video.txt", - "url": "/files/video.txt", - "path": "./output/video.txt" - } - ] -} -``` - -### GET /files/:filename -Serve a specific file. - -**Example:** -```bash -curl http://localhost:8888/files/video.mp3 --output video.mp3 -``` - ---- - -## Error Responses - -All endpoints return error responses in the following format: - -```json -{ - "error": "Error message describing what went wrong" -} -``` - -Common HTTP status codes: -- `400` - Bad Request (missing required parameters) -- `500` - Internal Server Error (processing failed) - ---- - -## Notes - -### Output Paths -All endpoints that support `outputPath` parameter: -- If not specified, files are saved to the default `OUTPUT_DIR` (./output) -- If specified, files are saved to the custom path provided - -### Models -- **Transcription**: Default is `gpt-4o-mini-transcribe` (cost-effective) -- **Summarization**: Default is `gpt-5.1` (latest GPT model) -- **Translation**: Uses `gpt-4o-mini` (hardcoded) - -### File Formats -- **Audio**: MP3, WAV, M4A, OGG, FLAC -- **Text**: TXT files -- **Transcription outputs**: TXT, JSON, SRT, VTT (depending on model) - -### API Key -Ensure `OPENAI_API_KEY` is set in your `.env` file for transcription, translation, and summarization features to work. +# API Documentation - Video to MP3 Transcriptor + +## Base URL +``` +http://localhost:8888 +``` + +## Table of Contents +- [Health & Info](#health--info) +- [Download Endpoints](#download-endpoints) +- [Transcription Endpoints](#transcription-endpoints) +- [Translation Endpoints](#translation-endpoints) +- [Summarization Endpoints](#summarization-endpoints) +- [File Management](#file-management) + +--- + +## Health & Info + +### GET /health +Health check endpoint. + +**Response:** +```json +{ + "status": "ok", + "timestamp": "2025-11-28T12:00:00.000Z" +} +``` + +### GET /api +Get API information and available endpoints. + +**Response:** +```json +{ + "name": "Video to MP3 Transcriptor API", + "version": "1.0.0", + "endpoints": { ... } +} +``` + +### GET /info +Get information about a YouTube video or playlist. + +**Query Parameters:** +- `url` (required): YouTube URL + +**Example:** +```bash +curl "http://localhost:8888/info?url=https://www.youtube.com/watch?v=VIDEO_ID" +``` + +**Response:** +```json +{ + "success": true, + "title": "Video Title", + "type": "video", + "duration": 300, + "channel": "Channel Name", + "videoCount": 1 +} +``` + +--- + +## Download Endpoints + +### GET /download-stream +Download YouTube video(s) to MP3 with Server-Sent Events (SSE) progress updates. + +**Query Parameters:** +- `url` (required): YouTube URL +- `outputPath` (optional): Custom output directory path + +**Example:** +```bash +curl "http://localhost:8888/download-stream?url=https://www.youtube.com/watch?v=VIDEO_ID" +``` + +**SSE Events:** +- `info`: Video/playlist information +- `progress`: Download progress updates +- `video-complete`: Individual video completion +- `complete`: All downloads complete +- `error`: Error occurred + +### POST /download +Download YouTube video(s) to MP3 (non-streaming). + +**Body Parameters:** +```json +{ + "url": "https://www.youtube.com/watch?v=VIDEO_ID", + "outputPath": "./custom/path" // optional +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8888/download \ + -H "Content-Type: application/json" \ + -d '{"url":"https://www.youtube.com/watch?v=VIDEO_ID"}' +``` + +**Response:** +```json +{ + "success": true, + "playlistTitle": null, + "totalVideos": 1, + "successCount": 1, + "failCount": 0, + "videos": [ + { + "success": true, + "title": "Video Title", + "filePath": "./output/video.mp3", + "fileUrl": "/files/video.mp3" + } + ] +} +``` + +--- + +## Transcription Endpoints + +### POST /transcribe +Transcribe an existing audio file. + +**Body Parameters:** +```json +{ + "filePath": "./output/audio.mp3", + "language": "en", // optional (auto-detect if not specified) + "format": "txt", // optional: txt, json, srt, vtt + "model": "gpt-4o-mini-transcribe", // optional: gpt-4o-mini-transcribe (default), gpt-4o-transcribe, whisper-1 + "outputPath": "./custom/path" // optional +} +``` + +**Available Models:** +- `gpt-4o-mini-transcribe` (default) - Fast and cost-effective +- `gpt-4o-transcribe` - Higher quality +- `whisper-1` - Original Whisper model (supports more formats) + +**Example:** +```bash +curl -X POST http://localhost:8888/transcribe \ + -H "Content-Type: application/json" \ + -d '{ + "filePath": "./output/audio.mp3", + "language": "en", + "model": "gpt-4o-mini-transcribe" + }' +``` + +**Response:** +```json +{ + "success": true, + "filePath": "./output/audio.mp3", + "transcriptionPath": "./output/audio.txt", + "transcriptionUrl": "/files/audio.txt", + "text": "Transcribed text content..." +} +``` + +### POST /upload-transcribe +Upload and transcribe audio files. + +**Form Data:** +- `files`: Audio file(s) (multiple files supported, max 50) +- `language`: Language code (optional) +- `model`: Transcription model (optional, default: gpt-4o-mini-transcribe) +- `outputPath`: Custom output directory (optional) + +**Example:** +```bash +curl -X POST http://localhost:8888/upload-transcribe \ + -F "files=@audio1.mp3" \ + -F "files=@audio2.mp3" \ + -F "language=en" \ + -F "model=gpt-4o-mini-transcribe" +``` + +**Response:** +```json +{ + "success": true, + "totalFiles": 2, + "successCount": 2, + "failCount": 0, + "results": [ + { + "success": true, + "fileName": "audio1.mp3", + "transcriptionPath": "./output/audio1.txt", + "transcriptionUrl": "/files/audio1.txt", + "text": "Transcription..." + } + ] +} +``` + +### GET /process-stream +Download + Transcribe with SSE progress updates. + +**Query Parameters:** +- `url` (required): YouTube URL +- `language` (optional): Language code +- `model` (optional): Transcription model (default: gpt-4o-mini-transcribe) +- `outputPath` (optional): Custom output directory + +**Example:** +```bash +curl "http://localhost:8888/process-stream?url=https://www.youtube.com/watch?v=VIDEO_ID&language=en&model=gpt-4o-mini-transcribe" +``` + +**SSE Events:** +- `info`: Video information +- `progress`: Progress updates (downloading or transcribing) +- `video-complete`: Download complete +- `transcribe-complete`: Transcription complete +- `complete`: All operations complete +- `error`: Error occurred + +### POST /process +Download + Transcribe (non-streaming). + +**Body Parameters:** +```json +{ + "url": "https://www.youtube.com/watch?v=VIDEO_ID", + "language": "en", // optional + "format": "txt", // optional + "model": "gpt-4o-mini-transcribe", // optional + "outputPath": "./custom/path" // optional +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8888/process \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://www.youtube.com/watch?v=VIDEO_ID", + "language": "en", + "model": "gpt-4o-mini-transcribe" + }' +``` + +**Response:** +```json +{ + "success": true, + "playlistTitle": null, + "totalVideos": 1, + "downloadedCount": 1, + "transcribedCount": 1, + "results": [ + { + "title": "Video Title", + "downloadSuccess": true, + "audioPath": "./output/video.mp3", + "audioUrl": "/files/video.mp3", + "transcriptionSuccess": true, + "transcriptionPath": "./output/video.txt", + "transcriptionUrl": "/files/video.txt", + "text": "Transcription..." + } + ] +} +``` + +--- + +## Translation Endpoints + +### GET /languages +Get available translation languages. + +**Response:** +```json +{ + "languages": { + "en": "English", + "fr": "French", + "es": "Spanish", + "de": "German", + "zh": "Chinese", + "ja": "Japanese", + ... + } +} +``` + +### POST /translate +Translate text. + +**Body Parameters:** +```json +{ + "text": "Text to translate", + "targetLang": "fr", // required: target language code + "sourceLang": "en" // optional: source language (auto-detect if not specified) +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8888/translate \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Hello, how are you?", + "targetLang": "fr" + }' +``` + +**Response:** +```json +{ + "success": true, + "originalText": "Hello, how are you?", + "translatedText": "Bonjour, comment allez-vous ?", + "targetLanguage": "French", + "sourceLanguage": "auto-detected", + "chunks": 1 +} +``` + +### POST /translate-file +Translate uploaded text files. + +**Form Data:** +- `files`: Text file(s) (.txt, multiple files supported, max 50) +- `targetLang`: Target language code (required) +- `sourceLang`: Source language code (optional) +- `outputPath`: Custom output directory (optional) + +**Example:** +```bash +curl -X POST http://localhost:8888/translate-file \ + -F "files=@document.txt" \ + -F "targetLang=fr" \ + -F "sourceLang=en" +``` + +**Response:** +```json +{ + "success": true, + "totalFiles": 1, + "successCount": 1, + "failCount": 0, + "results": [ + { + "success": true, + "fileName": "document.txt", + "translationPath": "./output/document_fr.txt", + "translationUrl": "/files/document_fr.txt", + "translatedText": "Translated content..." + } + ] +} +``` + +--- + +## Summarization Endpoints + +### GET /summary-styles +Get available summary styles. + +**Response:** +```json +{ + "styles": { + "concise": "A brief summary capturing main points", + "detailed": "A comprehensive summary with nuances", + "bullet": "Key points as bullet points" + } +} +``` + +### POST /summarize +Summarize text using GPT-5.1. + +**Body Parameters:** +```json +{ + "text": "Long text to summarize...", + "style": "concise", // optional: concise (default), detailed, bullet + "language": "same", // optional: 'same' (default) or language code + "model": "gpt-5.1" // optional: default is gpt-5.1 +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8888/summarize \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Long article content...", + "style": "bullet", + "language": "same" + }' +``` + +**Response:** +```json +{ + "success": true, + "summary": "Summary content...", + "model": "gpt-5.1", + "style": "bullet", + "inputLength": 5000, + "chunks": 1 +} +``` + +### POST /summarize-file +Summarize uploaded text files using GPT-5.1. + +**Form Data:** +- `files`: Text file(s) (.txt, multiple files supported, max 50) +- `style`: Summary style (optional, default: concise) +- `language`: Output language (optional, default: same) +- `model`: AI model (optional, default: gpt-5.1) +- `outputPath`: Custom output directory (optional) + +**Example:** +```bash +curl -X POST http://localhost:8888/summarize-file \ + -F "files=@article.txt" \ + -F "style=detailed" \ + -F "language=same" +``` + +**Response:** +```json +{ + "success": true, + "totalFiles": 1, + "successCount": 1, + "failCount": 0, + "results": [ + { + "success": true, + "fileName": "article.txt", + "summaryPath": "./output/article_summary.txt", + "summaryUrl": "/files/article_summary.txt", + "summary": "Summary content...", + "model": "gpt-5.1", + "chunks": 1 + } + ] +} +``` + +### GET /summarize-stream +Full pipeline: Download -> Transcribe -> Summarize with SSE progress. + +**Query Parameters:** +- `url` (required): YouTube URL +- `style` (optional): Summary style (default: concise) +- `language` (optional): Output language (default: same) +- `model` (optional): Transcription model (default: gpt-4o-mini-transcribe) +- `outputPath` (optional): Custom output directory + +**Example:** +```bash +curl "http://localhost:8888/summarize-stream?url=https://www.youtube.com/watch?v=VIDEO_ID&style=bullet&model=gpt-4o-mini-transcribe" +``` + +**SSE Events:** +- `info`: Video information +- `progress`: Progress updates (downloading, transcribing, or summarizing) +- `video-complete`: Download complete +- `transcribe-complete`: Transcription complete +- `summarize-complete`: Summary complete +- `complete`: All operations complete +- `error`: Error occurred + +--- + +## File Management + +### GET /files-list +List all downloaded/generated files. + +**Example:** +```bash +curl http://localhost:8888/files-list +``` + +**Response:** +```json +{ + "files": [ + { + "name": "video.mp3", + "url": "/files/video.mp3", + "path": "./output/video.mp3" + }, + { + "name": "video.txt", + "url": "/files/video.txt", + "path": "./output/video.txt" + } + ] +} +``` + +### GET /files/:filename +Serve a specific file. + +**Example:** +```bash +curl http://localhost:8888/files/video.mp3 --output video.mp3 +``` + +--- + +## Error Responses + +All endpoints return error responses in the following format: + +```json +{ + "error": "Error message describing what went wrong" +} +``` + +Common HTTP status codes: +- `400` - Bad Request (missing required parameters) +- `500` - Internal Server Error (processing failed) + +--- + +## Notes + +### Output Paths +All endpoints that support `outputPath` parameter: +- If not specified, files are saved to the default `OUTPUT_DIR` (./output) +- If specified, files are saved to the custom path provided + +### Models +- **Transcription**: Default is `gpt-4o-mini-transcribe` (cost-effective) +- **Summarization**: Default is `gpt-5.1` (latest GPT model) +- **Translation**: Uses `gpt-4o-mini` (hardcoded) + +### File Formats +- **Audio**: MP3, WAV, M4A, OGG, FLAC +- **Text**: TXT files +- **Transcription outputs**: TXT, JSON, SRT, VTT (depending on model) + +### API Key +Ensure `OPENAI_API_KEY` is set in your `.env` file for transcription, translation, and summarization features to work. diff --git a/nul b/nul new file mode 100644 index 0000000..42de59f --- /dev/null +++ b/nul @@ -0,0 +1 @@ +/usr/bin/bash: line 1: del: command not found diff --git a/public/app.js b/public/app.js index c1cf74d..76c17b3 100644 --- a/public/app.js +++ b/public/app.js @@ -1,1129 +1,1129 @@ -// API Base URL -const API_URL = ''; - -// Tab switching -document.querySelectorAll('.tab').forEach(tab => { - tab.addEventListener('click', () => { - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - tab.classList.add('active'); - document.getElementById(tab.dataset.tab).classList.add('active'); - }); -}); - -// Helper: Show result -function showResult(elementId, success, content) { - const el = document.getElementById(elementId); - el.className = `result show ${success ? 'success' : 'error'}`; - el.innerHTML = content; -} - -// Helper: Set loading state -function setLoading(button, loading) { - button.disabled = loading; - button.classList.toggle('loading', loading); -} - -// Format seconds to MM:SS or HH:MM:SS -function formatTime(seconds) { - if (!seconds || seconds < 0) return '--:--'; - const hrs = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - if (hrs > 0) { - return `${hrs}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; - } - return `${mins}:${String(secs).padStart(2, '0')}`; -} - -// Format file size -function formatSize(bytes) { - if (!bytes) return ''; - const units = ['B', 'KB', 'MB', 'GB']; - let i = 0; - while (bytes >= 1024 && i < units.length - 1) { - bytes /= 1024; - i++; - } - return `${bytes.toFixed(1)} ${units[i]}`; -} - -// ==================== DOWNLOAD TAB ==================== - -const progressContainer = document.getElementById('download-progress'); -const progressFill = document.getElementById('progress-fill'); -const progressPercent = document.getElementById('progress-percent'); -const progressTitle = document.getElementById('progress-title'); -const progressEta = document.getElementById('progress-eta'); -const progressInfo = document.getElementById('progress-info'); -const progressSpeed = document.getElementById('progress-speed'); -const progressCurrent = document.getElementById('progress-current'); - -function updateDownloadProgress(data) { - progressFill.style.width = `${data.percent}%`; - progressPercent.textContent = `${data.percent}%`; - - if (data.totalVideos > 1) { - progressInfo.textContent = `Video ${data.currentVideo}/${data.totalVideos}`; - } else { - progressInfo.textContent = ''; - } - - if (data.speed) progressSpeed.textContent = data.speed; - if (data.estimatedRemaining) { - progressEta.textContent = `ETA: ${formatTime(data.estimatedRemaining)}`; - } else if (data.eta) { - progressEta.textContent = `ETA: ${data.eta}`; - } - if (data.title) { - progressCurrent.innerHTML = `Downloading: ${data.title}`; - } -} - -function resetDownloadProgress() { - progressFill.style.width = '0%'; - progressPercent.textContent = '0%'; - progressTitle.textContent = 'Downloading...'; - progressEta.textContent = ''; - progressInfo.textContent = ''; - progressSpeed.textContent = ''; - progressCurrent.textContent = ''; -} - -document.getElementById('download-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const button = e.target.querySelector('button[type="submit"]'); - const url = document.getElementById('download-url').value; - const resultDiv = document.getElementById('download-result'); - - setLoading(button, true); - resetDownloadProgress(); - progressContainer.style.display = 'block'; - resultDiv.classList.remove('show'); - - const eventSource = new EventSource(`${API_URL}/download-stream?url=${encodeURIComponent(url)}`); - - eventSource.addEventListener('status', (e) => { - progressTitle.textContent = JSON.parse(e.data).message; - }); - - eventSource.addEventListener('info', (e) => { - const data = JSON.parse(e.data); - progressTitle.textContent = data.totalVideos > 1 - ? `Downloading playlist: ${data.playlistTitle} (${data.totalVideos} videos)` - : `Downloading: ${data.title}`; - }); - - eventSource.addEventListener('progress', (e) => updateDownloadProgress(JSON.parse(e.data))); - - eventSource.addEventListener('video-complete', (e) => { - const data = JSON.parse(e.data); - progressCurrent.innerHTML = `Completed: ${data.title} (${data.videosCompleted}/${data.totalVideos})`; - }); - - eventSource.addEventListener('complete', (e) => { - const data = JSON.parse(e.data); - eventSource.close(); - progressFill.style.width = '100%'; - progressPercent.textContent = '100%'; - progressTitle.textContent = 'Download Complete!'; - progressEta.textContent = `Total: ${formatTime(data.totalTime)}`; - - showResult('download-result', true, ` -

Download Complete!

-

${data.successCount}/${data.totalVideos} videos downloaded

- ${data.playlistTitle ? `

Playlist: ${data.playlistTitle}

` : ''} - - `); - setLoading(button, false); - }); - - eventSource.addEventListener('error', (e) => { - let errorMsg = 'Download failed'; - try { errorMsg = JSON.parse(e.data).message || errorMsg; } catch {} - eventSource.close(); - progressContainer.style.display = 'none'; - showResult('download-result', false, `

Error

${errorMsg}

`); - setLoading(button, false); - }); - - eventSource.onerror = () => { - eventSource.close(); - progressContainer.style.display = 'none'; - showResult('download-result', false, `

Error

Connection lost

`); - setLoading(button, false); - }; -}); - -// ==================== CONVERT TAB (Video to MP3) ==================== - -let convertSelectedFiles = []; -const convertDropZone = document.getElementById('convert-drop-zone'); -const convertFileInput = document.getElementById('convert-file-input'); -const convertSelectedFilesDiv = document.getElementById('convert-selected-files'); -const convertFilesList = document.getElementById('convert-files-list'); -const convertBtn = document.getElementById('convert-btn'); -const convertClearFilesBtn = document.getElementById('convert-clear-files'); - -function updateConvertFilesList() { - if (convertSelectedFiles.length === 0) { - convertSelectedFilesDiv.style.display = 'none'; - convertBtn.disabled = true; - return; - } - - convertSelectedFilesDiv.style.display = 'block'; - convertBtn.disabled = false; - - convertFilesList.innerHTML = convertSelectedFiles.map((file, index) => ` -
  • - ${file.name} - ${formatSize(file.size)} - -
  • - `).join(''); - - // Add remove handlers - convertFilesList.querySelectorAll('.remove-file').forEach(btn => { - btn.addEventListener('click', () => { - convertSelectedFiles.splice(parseInt(btn.dataset.index), 1); - updateConvertFilesList(); - }); - }); -} - -function addConvertFiles(files) { - const videoAudioFiles = Array.from(files).filter(f => - f.type.startsWith('video/') || f.type.startsWith('audio/') || - f.name.match(/\.(mp4|avi|mkv|mov|m4a|wav|flac|ogg|webm|wmv|flv)$/i) - ); - convertSelectedFiles = [...convertSelectedFiles, ...videoAudioFiles]; - updateConvertFilesList(); -} - -// Drag & Drop events for convert -convertDropZone.addEventListener('click', () => convertFileInput.click()); - -convertDropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - convertDropZone.classList.add('drag-over'); -}); - -convertDropZone.addEventListener('dragleave', () => { - convertDropZone.classList.remove('drag-over'); -}); - -convertDropZone.addEventListener('drop', (e) => { - e.preventDefault(); - convertDropZone.classList.remove('drag-over'); - addConvertFiles(e.dataTransfer.files); -}); - -convertFileInput.addEventListener('change', () => { - addConvertFiles(convertFileInput.files); - convertFileInput.value = ''; -}); - -convertClearFilesBtn.addEventListener('click', () => { - convertSelectedFiles = []; - updateConvertFilesList(); -}); - -// Convert form submit -document.getElementById('convert-form').addEventListener('submit', async (e) => { - e.preventDefault(); - if (convertSelectedFiles.length === 0) return; - - const button = convertBtn; - const bitrate = document.getElementById('convert-bitrate').value; - const quality = document.getElementById('convert-quality').value; - - const convertProgress = document.getElementById('convert-progress'); - const convertProgressFill = document.getElementById('convert-progress-fill'); - const convertProgressTitle = document.getElementById('convert-progress-title'); - const convertProgressPercent = document.getElementById('convert-progress-percent'); - const convertProgressInfo = document.getElementById('convert-progress-info'); - const convertProgressCurrent = document.getElementById('convert-progress-current'); - - setLoading(button, true); - convertProgress.style.display = 'block'; - convertProgressFill.style.width = '0%'; - convertProgressTitle.textContent = 'Converting to MP3...'; - convertProgressPercent.textContent = '0%'; - convertProgressInfo.textContent = `0/${convertSelectedFiles.length} files`; - document.getElementById('convert-result').classList.remove('show'); - - const formData = new FormData(); - convertSelectedFiles.forEach(file => formData.append('files', file)); - formData.append('bitrate', bitrate); - formData.append('quality', quality); - - try { - const response = await fetch(`${API_URL}/convert-to-mp3`, { - method: 'POST', - body: formData - }); - - const data = await response.json(); - - if (!response.ok) throw new Error(data.error || 'Conversion failed'); - - convertProgressFill.style.width = '100%'; - convertProgressPercent.textContent = '100%'; - convertProgressTitle.textContent = 'Conversion Complete!'; - convertProgressInfo.textContent = `${data.successCount}/${data.totalFiles} files`; - - showResult('convert-result', true, ` -

    Conversion Complete!

    -

    ${data.successCount}/${data.totalFiles} files converted to MP3

    -
    - ${data.results.map(r => r.success ? ` -
    -
    - ${r.fileName} - ${r.size} -
    - Download MP3 -
    - ` : ` -
    - ${r.fileName} - ${r.error} -
    - `).join('')} -
    - `); - - // Clear selected files after successful conversion - convertSelectedFiles = []; - updateConvertFilesList(); - - } catch (error) { - showResult('convert-result', false, `

    Error

    ${error.message}

    `); - } finally { - setLoading(button, false); - setTimeout(() => { - convertProgress.style.display = 'none'; - }, 1000); - } -}); - -// ==================== TRANSCRIBE TAB (Drag & Drop) ==================== - -let selectedFiles = []; -const dropZone = document.getElementById('drop-zone'); -const fileInput = document.getElementById('file-input'); -const selectedFilesDiv = document.getElementById('selected-files'); -const filesList = document.getElementById('files-list'); -const transcribeBtn = document.getElementById('transcribe-btn'); -const clearFilesBtn = document.getElementById('clear-files'); - -function updateFilesList() { - if (selectedFiles.length === 0) { - selectedFilesDiv.style.display = 'none'; - transcribeBtn.disabled = true; - return; - } - - selectedFilesDiv.style.display = 'block'; - transcribeBtn.disabled = false; - - filesList.innerHTML = selectedFiles.map((file, index) => ` -
  • - ${file.name} - ${formatSize(file.size)} - -
  • - `).join(''); - - // Add remove handlers - filesList.querySelectorAll('.remove-file').forEach(btn => { - btn.addEventListener('click', () => { - selectedFiles.splice(parseInt(btn.dataset.index), 1); - updateFilesList(); - }); - }); -} - -function addFiles(files) { - const audioFiles = Array.from(files).filter(f => - f.type.startsWith('audio/') || f.name.match(/\.(mp3|wav|m4a|ogg|flac)$/i) - ); - selectedFiles = [...selectedFiles, ...audioFiles]; - updateFilesList(); -} - -// Drag & Drop events -dropZone.addEventListener('click', () => fileInput.click()); - -dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('drag-over'); -}); - -dropZone.addEventListener('dragleave', () => { - dropZone.classList.remove('drag-over'); -}); - -dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('drag-over'); - addFiles(e.dataTransfer.files); -}); - -fileInput.addEventListener('change', () => { - addFiles(fileInput.files); - fileInput.value = ''; -}); - -clearFilesBtn.addEventListener('click', () => { - selectedFiles = []; - updateFilesList(); -}); - -// Transcribe form submit -document.getElementById('transcribe-form').addEventListener('submit', async (e) => { - e.preventDefault(); - if (selectedFiles.length === 0) return; - - const button = transcribeBtn; - const language = document.getElementById('transcribe-lang').value; - const model = document.getElementById('transcribe-model').value; - - const transcribeProgress = document.getElementById('transcribe-progress'); - const transcribeProgressFill = document.getElementById('transcribe-progress-fill'); - const transcribeProgressTitle = document.getElementById('transcribe-progress-title'); - const transcribeProgressPercent = document.getElementById('transcribe-progress-percent'); - const transcribeProgressInfo = document.getElementById('transcribe-progress-info'); - const transcribeProgressCurrent = document.getElementById('transcribe-progress-current'); - - setLoading(button, true); - transcribeProgress.style.display = 'block'; - transcribeProgressFill.style.width = '0%'; - transcribeProgressTitle.textContent = 'Uploading and transcribing...'; - transcribeProgressPercent.textContent = '0%'; - transcribeProgressInfo.textContent = `0/${selectedFiles.length} files`; - document.getElementById('transcribe-result').classList.remove('show'); - - const formData = new FormData(); - selectedFiles.forEach(file => formData.append('files', file)); - if (language) formData.append('language', language); - formData.append('model', model); - - try { - const response = await fetch(`${API_URL}/upload-transcribe`, { - method: 'POST', - body: formData - }); - - const data = await response.json(); - - if (!response.ok) throw new Error(data.error || 'Transcription failed'); - - transcribeProgressFill.style.width = '100%'; - transcribeProgressPercent.textContent = '100%'; - transcribeProgressTitle.textContent = 'Transcription Complete!'; - transcribeProgressInfo.textContent = `${data.successCount}/${data.totalFiles} files`; - - showResult('transcribe-result', true, ` -

    Transcription Complete!

    -

    ${data.successCount}/${data.totalFiles} files transcribed

    - - ${data.results[0]?.text ? ` -

    Preview (first file):

    -
    ${data.results[0].text.substring(0, 1000)}${data.results[0].text.length > 1000 ? '...' : ''}
    - ` : ''} - `); - - selectedFiles = []; - updateFilesList(); - - } catch (error) { - transcribeProgress.style.display = 'none'; - showResult('transcribe-result', false, `

    Error

    ${error.message}

    `); - } finally { - setLoading(button, false); - } -}); - -// ==================== PROCESS TAB (Download + Transcribe) ==================== - -const processProgress = document.getElementById('process-progress'); -const processProgressFill = document.getElementById('process-progress-fill'); -const processProgressTitle = document.getElementById('process-progress-title'); -const processProgressPercent = document.getElementById('process-progress-percent'); -const processProgressPhase = document.getElementById('process-progress-phase'); -const processProgressSpeed = document.getElementById('process-progress-speed'); -const processProgressCurrent = document.getElementById('process-progress-current'); -const processProgressEta = document.getElementById('process-progress-eta'); - -function resetProcessProgress() { - processProgressFill.style.width = '0%'; - processProgressPercent.textContent = '0%'; - processProgressTitle.textContent = 'Processing...'; - processProgressPhase.textContent = ''; - processProgressSpeed.textContent = ''; - processProgressCurrent.textContent = ''; - processProgressEta.textContent = ''; -} - -document.getElementById('process-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const button = e.target.querySelector('button[type="submit"]'); - const url = document.getElementById('process-url').value; - const language = document.getElementById('process-lang').value; - const model = document.getElementById('process-model').value; - const resultDiv = document.getElementById('process-result'); - - setLoading(button, true); - resetProcessProgress(); - processProgress.style.display = 'block'; - resultDiv.classList.remove('show'); - - const params = new URLSearchParams({ url }); - if (language) params.append('language', language); - params.append('model', model); - - const eventSource = new EventSource(`${API_URL}/process-stream?${params}`); - - eventSource.addEventListener('status', (e) => { - const data = JSON.parse(e.data); - processProgressTitle.textContent = data.message; - if (data.phase === 'transcribing') { - processProgressPhase.textContent = 'Transcribing'; - } - }); - - eventSource.addEventListener('info', (e) => { - const data = JSON.parse(e.data); - processProgressTitle.textContent = data.totalVideos > 1 - ? `Processing playlist: ${data.playlistTitle} (${data.totalVideos} videos)` - : `Processing: ${data.title}`; - }); - - eventSource.addEventListener('progress', (e) => { - const data = JSON.parse(e.data); - processProgressFill.style.width = `${data.percent}%`; - processProgressPercent.textContent = `${Math.round(data.percent)}%`; - processProgressPhase.textContent = data.phaseLabel || ''; - if (data.speed) processProgressSpeed.textContent = data.speed; - if (data.title) { - processProgressCurrent.innerHTML = `${data.phaseLabel}: ${data.title}`; - } - if (data.totalVideos > 1) { - processProgressCurrent.innerHTML += ` (${data.currentVideo}/${data.totalVideos})`; - } - }); - - eventSource.addEventListener('video-complete', (e) => { - const data = JSON.parse(e.data); - processProgressCurrent.innerHTML = `Downloaded: ${data.title}`; - }); - - eventSource.addEventListener('transcribe-complete', (e) => { - const data = JSON.parse(e.data); - processProgressCurrent.innerHTML = `Transcribed: ${data.title} (${data.videosCompleted}/${data.totalFiles})`; - }); - - eventSource.addEventListener('complete', (e) => { - const data = JSON.parse(e.data); - eventSource.close(); - processProgressFill.style.width = '100%'; - processProgressPercent.textContent = '100%'; - processProgressTitle.textContent = 'Processing Complete!'; - processProgressPhase.textContent = ''; - processProgressEta.textContent = `Total: ${formatTime(data.totalTime)}`; - - showResult('process-result', true, ` -

    Processing Complete!

    - ${data.playlistTitle ? `

    Playlist: ${data.playlistTitle}

    ` : ''} -

    Downloaded: ${data.downloadedCount}/${data.totalVideos}

    -

    Transcribed: ${data.transcribedCount}/${data.totalVideos}

    - - ${data.results[0]?.text ? ` -

    Preview (first file):

    -
    ${data.results[0].text.substring(0, 1000)}${data.results[0].text.length > 1000 ? '...' : ''}
    - ` : ''} - `); - setLoading(button, false); - }); - - eventSource.addEventListener('error', (e) => { - let errorMsg = 'Processing failed'; - try { errorMsg = JSON.parse(e.data).message || errorMsg; } catch {} - eventSource.close(); - processProgress.style.display = 'none'; - showResult('process-result', false, `

    Error

    ${errorMsg}

    `); - setLoading(button, false); - }); - - eventSource.onerror = () => { - eventSource.close(); - processProgress.style.display = 'none'; - showResult('process-result', false, `

    Error

    Connection lost

    `); - setLoading(button, false); - }; -}); - -// ==================== TRANSLATE CHECKBOXES (Transcribe & Process tabs) ==================== - -// Transcribe tab checkbox -const transcribeTranslateCheckbox = document.getElementById('transcribe-translate'); -const transcribeTranslateLang = document.getElementById('transcribe-translate-lang'); - -transcribeTranslateCheckbox.addEventListener('change', () => { - transcribeTranslateLang.disabled = !transcribeTranslateCheckbox.checked; -}); - -// Process tab checkbox -const processTranslateCheckbox = document.getElementById('process-translate'); -const processTranslateLang = document.getElementById('process-translate-lang'); - -processTranslateCheckbox.addEventListener('change', () => { - processTranslateLang.disabled = !processTranslateCheckbox.checked; -}); - -// ==================== TRANSLATE TAB ==================== - -// Mode switching -const translateModeBtns = document.querySelectorAll('.mode-btn'); -const translateTextMode = document.getElementById('translate-text-mode'); -const translateFileMode = document.getElementById('translate-file-mode'); - -translateModeBtns.forEach(btn => { - btn.addEventListener('click', () => { - translateModeBtns.forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - if (btn.dataset.mode === 'text') { - translateTextMode.style.display = 'block'; - translateFileMode.style.display = 'none'; - } else { - translateTextMode.style.display = 'none'; - translateFileMode.style.display = 'block'; - } - }); -}); - -// Text translation form -document.getElementById('translate-text-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const button = document.getElementById('translate-text-btn'); - const text = document.getElementById('translate-input').value; - const sourceLang = document.getElementById('translate-source').value; - const targetLang = document.getElementById('translate-target').value; - - if (!text.trim()) { - showResult('translate-text-result', false, '

    Error

    Please enter text to translate

    '); - return; - } - - setLoading(button, true); - document.getElementById('translate-text-result').classList.remove('show'); - - try { - const response = await fetch(`${API_URL}/translate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, targetLang, sourceLang: sourceLang || null }) - }); - - const data = await response.json(); - if (!response.ok) throw new Error(data.error || 'Translation failed'); - - showResult('translate-text-result', true, ` -

    Translation Complete!

    -

    From: ${data.sourceLanguage} To: ${data.targetLanguage}

    -
    ${data.translatedText}
    - `); - } catch (error) { - showResult('translate-text-result', false, `

    Error

    ${error.message}

    `); - } finally { - setLoading(button, false); - } -}); - -// File translation - Drag & Drop -let translateSelectedFiles = []; -const translateDropZone = document.getElementById('translate-drop-zone'); -const translateFileInput = document.getElementById('translate-file-input'); -const translateSelectedFilesDiv = document.getElementById('translate-selected-files'); -const translateFilesList = document.getElementById('translate-files-list'); -const translateFileBtn = document.getElementById('translate-file-btn'); -const translateClearFilesBtn = document.getElementById('translate-clear-files'); - -function updateTranslateFilesList() { - if (translateSelectedFiles.length === 0) { - translateSelectedFilesDiv.style.display = 'none'; - translateFileBtn.disabled = true; - return; - } - - translateSelectedFilesDiv.style.display = 'block'; - translateFileBtn.disabled = false; - - translateFilesList.innerHTML = translateSelectedFiles.map((file, index) => ` -
  • - ${file.name} - ${formatSize(file.size)} - -
  • - `).join(''); - - translateFilesList.querySelectorAll('.remove-file').forEach(btn => { - btn.addEventListener('click', () => { - translateSelectedFiles.splice(parseInt(btn.dataset.index), 1); - updateTranslateFilesList(); - }); - }); -} - -function addTranslateFiles(files) { - const textFiles = Array.from(files).filter(f => - f.type === 'text/plain' || f.name.endsWith('.txt') - ); - translateSelectedFiles = [...translateSelectedFiles, ...textFiles]; - updateTranslateFilesList(); -} - -translateDropZone.addEventListener('click', () => translateFileInput.click()); - -translateDropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - translateDropZone.classList.add('drag-over'); -}); - -translateDropZone.addEventListener('dragleave', () => { - translateDropZone.classList.remove('drag-over'); -}); - -translateDropZone.addEventListener('drop', (e) => { - e.preventDefault(); - translateDropZone.classList.remove('drag-over'); - addTranslateFiles(e.dataTransfer.files); -}); - -translateFileInput.addEventListener('change', () => { - addTranslateFiles(translateFileInput.files); - translateFileInput.value = ''; -}); - -translateClearFilesBtn.addEventListener('click', () => { - translateSelectedFiles = []; - updateTranslateFilesList(); -}); - -// File translation form submit -document.getElementById('translate-file-form').addEventListener('submit', async (e) => { - e.preventDefault(); - if (translateSelectedFiles.length === 0) return; - - const button = translateFileBtn; - const sourceLang = document.getElementById('translate-file-source').value; - const targetLang = document.getElementById('translate-file-target').value; - - setLoading(button, true); - document.getElementById('translate-file-result').classList.remove('show'); - - const formData = new FormData(); - translateSelectedFiles.forEach(file => formData.append('files', file)); - formData.append('targetLang', targetLang); - if (sourceLang) formData.append('sourceLang', sourceLang); - - try { - const response = await fetch(`${API_URL}/translate-file`, { - method: 'POST', - body: formData - }); - - const data = await response.json(); - if (!response.ok) throw new Error(data.error || 'Translation failed'); - - showResult('translate-file-result', true, ` -

    Translation Complete!

    -

    ${data.successCount}/${data.totalFiles} files translated

    - - ${data.results[0]?.translatedText ? ` -

    Preview (first file):

    -
    ${data.results[0].translatedText.substring(0, 1000)}${data.results[0].translatedText.length > 1000 ? '...' : ''}
    - ` : ''} - `); - - translateSelectedFiles = []; - updateTranslateFilesList(); - - } catch (error) { - showResult('translate-file-result', false, `

    Error

    ${error.message}

    `); - } finally { - 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, '

    Error

    Please enter text to summarize

    '); - 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, ` -

    Summary Complete!

    -

    Model: ${data.model} | Style: ${data.style} | Chunks: ${data.chunks}

    -
    ${data.summary}
    - `); - } catch (error) { - showResult('summarize-text-result', false, `

    Error

    ${error.message}

    `); - } 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) => ` -
  • - ${file.name} - ${formatSize(file.size)} - -
  • - `).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, ` -

    Summarization Complete!

    -

    ${data.successCount}/${data.totalFiles} files summarized

    - - ${data.results[0]?.summary ? ` -

    Preview (first file):

    -
    ${data.results[0].summary.substring(0, 1000)}${data.results[0].summary.length > 1000 ? '...' : ''}
    - ` : ''} - `); - - summarizeSelectedFiles = []; - updateSummarizeFilesList(); - - } catch (error) { - showResult('summarize-file-result', false, `

    Error

    ${error.message}

    `); - } 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}: ${data.title}`; - } - if (data.totalVideos > 1) { - linkProgressCurrent.innerHTML += ` (${data.currentVideo}/${data.totalVideos})`; - } - }); - - eventSource.addEventListener('video-complete', (e) => { - const data = JSON.parse(e.data); - linkProgressCurrent.innerHTML = `Downloaded: ${data.title}`; - }); - - eventSource.addEventListener('transcribe-complete', (e) => { - const data = JSON.parse(e.data); - linkProgressCurrent.innerHTML = `Transcribed: ${data.title}`; - }); - - eventSource.addEventListener('summarize-complete', (e) => { - const data = JSON.parse(e.data); - linkProgressCurrent.innerHTML = `Summarized: ${data.title}`; - }); - - 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, ` -

    Pipeline Complete!

    - ${data.playlistTitle ? `

    Playlist: ${data.playlistTitle}

    ` : ''} -

    Downloaded: ${data.downloadedCount}/${data.totalVideos} | Transcribed: ${data.transcribedCount} | Summarized: ${data.summarizedCount}

    - - ${data.results[0]?.summary ? ` -

    Summary Preview:

    -
    ${data.results[0].summary.substring(0, 2000)}${data.results[0].summary.length > 2000 ? '...' : ''}
    - ` : ''} - `); - 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, `

    Error

    ${errorMsg}

    `); - setLoading(button, false); - }); - - eventSource.onerror = () => { - eventSource.close(); - linkProgress.style.display = 'none'; - showResult('summarize-link-result', false, `

    Error

    Connection lost

    `); - setLoading(button, false); - }; - }); -} +// API Base URL +const API_URL = ''; + +// Tab switching +document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(tab.dataset.tab).classList.add('active'); + }); +}); + +// Helper: Show result +function showResult(elementId, success, content) { + const el = document.getElementById(elementId); + el.className = `result show ${success ? 'success' : 'error'}`; + el.innerHTML = content; +} + +// Helper: Set loading state +function setLoading(button, loading) { + button.disabled = loading; + button.classList.toggle('loading', loading); +} + +// Format seconds to MM:SS or HH:MM:SS +function formatTime(seconds) { + if (!seconds || seconds < 0) return '--:--'; + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hrs > 0) { + return `${hrs}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } + return `${mins}:${String(secs).padStart(2, '0')}`; +} + +// Format file size +function formatSize(bytes) { + if (!bytes) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + while (bytes >= 1024 && i < units.length - 1) { + bytes /= 1024; + i++; + } + return `${bytes.toFixed(1)} ${units[i]}`; +} + +// ==================== DOWNLOAD TAB ==================== + +const progressContainer = document.getElementById('download-progress'); +const progressFill = document.getElementById('progress-fill'); +const progressPercent = document.getElementById('progress-percent'); +const progressTitle = document.getElementById('progress-title'); +const progressEta = document.getElementById('progress-eta'); +const progressInfo = document.getElementById('progress-info'); +const progressSpeed = document.getElementById('progress-speed'); +const progressCurrent = document.getElementById('progress-current'); + +function updateDownloadProgress(data) { + progressFill.style.width = `${data.percent}%`; + progressPercent.textContent = `${data.percent}%`; + + if (data.totalVideos > 1) { + progressInfo.textContent = `Video ${data.currentVideo}/${data.totalVideos}`; + } else { + progressInfo.textContent = ''; + } + + if (data.speed) progressSpeed.textContent = data.speed; + if (data.estimatedRemaining) { + progressEta.textContent = `ETA: ${formatTime(data.estimatedRemaining)}`; + } else if (data.eta) { + progressEta.textContent = `ETA: ${data.eta}`; + } + if (data.title) { + progressCurrent.innerHTML = `Downloading: ${data.title}`; + } +} + +function resetDownloadProgress() { + progressFill.style.width = '0%'; + progressPercent.textContent = '0%'; + progressTitle.textContent = 'Downloading...'; + progressEta.textContent = ''; + progressInfo.textContent = ''; + progressSpeed.textContent = ''; + progressCurrent.textContent = ''; +} + +document.getElementById('download-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const button = e.target.querySelector('button[type="submit"]'); + const url = document.getElementById('download-url').value; + const resultDiv = document.getElementById('download-result'); + + setLoading(button, true); + resetDownloadProgress(); + progressContainer.style.display = 'block'; + resultDiv.classList.remove('show'); + + const eventSource = new EventSource(`${API_URL}/download-stream?url=${encodeURIComponent(url)}`); + + eventSource.addEventListener('status', (e) => { + progressTitle.textContent = JSON.parse(e.data).message; + }); + + eventSource.addEventListener('info', (e) => { + const data = JSON.parse(e.data); + progressTitle.textContent = data.totalVideos > 1 + ? `Downloading playlist: ${data.playlistTitle} (${data.totalVideos} videos)` + : `Downloading: ${data.title}`; + }); + + eventSource.addEventListener('progress', (e) => updateDownloadProgress(JSON.parse(e.data))); + + eventSource.addEventListener('video-complete', (e) => { + const data = JSON.parse(e.data); + progressCurrent.innerHTML = `Completed: ${data.title} (${data.videosCompleted}/${data.totalVideos})`; + }); + + eventSource.addEventListener('complete', (e) => { + const data = JSON.parse(e.data); + eventSource.close(); + progressFill.style.width = '100%'; + progressPercent.textContent = '100%'; + progressTitle.textContent = 'Download Complete!'; + progressEta.textContent = `Total: ${formatTime(data.totalTime)}`; + + showResult('download-result', true, ` +

    Download Complete!

    +

    ${data.successCount}/${data.totalVideos} videos downloaded

    + ${data.playlistTitle ? `

    Playlist: ${data.playlistTitle}

    ` : ''} + + `); + setLoading(button, false); + }); + + eventSource.addEventListener('error', (e) => { + let errorMsg = 'Download failed'; + try { errorMsg = JSON.parse(e.data).message || errorMsg; } catch {} + eventSource.close(); + progressContainer.style.display = 'none'; + showResult('download-result', false, `

    Error

    ${errorMsg}

    `); + setLoading(button, false); + }); + + eventSource.onerror = () => { + eventSource.close(); + progressContainer.style.display = 'none'; + showResult('download-result', false, `

    Error

    Connection lost

    `); + setLoading(button, false); + }; +}); + +// ==================== CONVERT TAB (Video to MP3) ==================== + +let convertSelectedFiles = []; +const convertDropZone = document.getElementById('convert-drop-zone'); +const convertFileInput = document.getElementById('convert-file-input'); +const convertSelectedFilesDiv = document.getElementById('convert-selected-files'); +const convertFilesList = document.getElementById('convert-files-list'); +const convertBtn = document.getElementById('convert-btn'); +const convertClearFilesBtn = document.getElementById('convert-clear-files'); + +function updateConvertFilesList() { + if (convertSelectedFiles.length === 0) { + convertSelectedFilesDiv.style.display = 'none'; + convertBtn.disabled = true; + return; + } + + convertSelectedFilesDiv.style.display = 'block'; + convertBtn.disabled = false; + + convertFilesList.innerHTML = convertSelectedFiles.map((file, index) => ` +
  • + ${file.name} + ${formatSize(file.size)} + +
  • + `).join(''); + + // Add remove handlers + convertFilesList.querySelectorAll('.remove-file').forEach(btn => { + btn.addEventListener('click', () => { + convertSelectedFiles.splice(parseInt(btn.dataset.index), 1); + updateConvertFilesList(); + }); + }); +} + +function addConvertFiles(files) { + const videoAudioFiles = Array.from(files).filter(f => + f.type.startsWith('video/') || f.type.startsWith('audio/') || + f.name.match(/\.(mp4|avi|mkv|mov|m4a|wav|flac|ogg|webm|wmv|flv)$/i) + ); + convertSelectedFiles = [...convertSelectedFiles, ...videoAudioFiles]; + updateConvertFilesList(); +} + +// Drag & Drop events for convert +convertDropZone.addEventListener('click', () => convertFileInput.click()); + +convertDropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + convertDropZone.classList.add('drag-over'); +}); + +convertDropZone.addEventListener('dragleave', () => { + convertDropZone.classList.remove('drag-over'); +}); + +convertDropZone.addEventListener('drop', (e) => { + e.preventDefault(); + convertDropZone.classList.remove('drag-over'); + addConvertFiles(e.dataTransfer.files); +}); + +convertFileInput.addEventListener('change', () => { + addConvertFiles(convertFileInput.files); + convertFileInput.value = ''; +}); + +convertClearFilesBtn.addEventListener('click', () => { + convertSelectedFiles = []; + updateConvertFilesList(); +}); + +// Convert form submit +document.getElementById('convert-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (convertSelectedFiles.length === 0) return; + + const button = convertBtn; + const bitrate = document.getElementById('convert-bitrate').value; + const quality = document.getElementById('convert-quality').value; + + const convertProgress = document.getElementById('convert-progress'); + const convertProgressFill = document.getElementById('convert-progress-fill'); + const convertProgressTitle = document.getElementById('convert-progress-title'); + const convertProgressPercent = document.getElementById('convert-progress-percent'); + const convertProgressInfo = document.getElementById('convert-progress-info'); + const convertProgressCurrent = document.getElementById('convert-progress-current'); + + setLoading(button, true); + convertProgress.style.display = 'block'; + convertProgressFill.style.width = '0%'; + convertProgressTitle.textContent = 'Converting to MP3...'; + convertProgressPercent.textContent = '0%'; + convertProgressInfo.textContent = `0/${convertSelectedFiles.length} files`; + document.getElementById('convert-result').classList.remove('show'); + + const formData = new FormData(); + convertSelectedFiles.forEach(file => formData.append('files', file)); + formData.append('bitrate', bitrate); + formData.append('quality', quality); + + try { + const response = await fetch(`${API_URL}/convert-to-mp3`, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.error || 'Conversion failed'); + + convertProgressFill.style.width = '100%'; + convertProgressPercent.textContent = '100%'; + convertProgressTitle.textContent = 'Conversion Complete!'; + convertProgressInfo.textContent = `${data.successCount}/${data.totalFiles} files`; + + showResult('convert-result', true, ` +

    Conversion Complete!

    +

    ${data.successCount}/${data.totalFiles} files converted to MP3

    +
    + ${data.results.map(r => r.success ? ` +
    +
    + ${r.fileName} + ${r.size} +
    + Download MP3 +
    + ` : ` +
    + ${r.fileName} + ${r.error} +
    + `).join('')} +
    + `); + + // Clear selected files after successful conversion + convertSelectedFiles = []; + updateConvertFilesList(); + + } catch (error) { + showResult('convert-result', false, `

    Error

    ${error.message}

    `); + } finally { + setLoading(button, false); + setTimeout(() => { + convertProgress.style.display = 'none'; + }, 1000); + } +}); + +// ==================== TRANSCRIBE TAB (Drag & Drop) ==================== + +let selectedFiles = []; +const dropZone = document.getElementById('drop-zone'); +const fileInput = document.getElementById('file-input'); +const selectedFilesDiv = document.getElementById('selected-files'); +const filesList = document.getElementById('files-list'); +const transcribeBtn = document.getElementById('transcribe-btn'); +const clearFilesBtn = document.getElementById('clear-files'); + +function updateFilesList() { + if (selectedFiles.length === 0) { + selectedFilesDiv.style.display = 'none'; + transcribeBtn.disabled = true; + return; + } + + selectedFilesDiv.style.display = 'block'; + transcribeBtn.disabled = false; + + filesList.innerHTML = selectedFiles.map((file, index) => ` +
  • + ${file.name} + ${formatSize(file.size)} + +
  • + `).join(''); + + // Add remove handlers + filesList.querySelectorAll('.remove-file').forEach(btn => { + btn.addEventListener('click', () => { + selectedFiles.splice(parseInt(btn.dataset.index), 1); + updateFilesList(); + }); + }); +} + +function addFiles(files) { + const audioFiles = Array.from(files).filter(f => + f.type.startsWith('audio/') || f.name.match(/\.(mp3|wav|m4a|ogg|flac)$/i) + ); + selectedFiles = [...selectedFiles, ...audioFiles]; + updateFilesList(); +} + +// Drag & Drop events +dropZone.addEventListener('click', () => fileInput.click()); + +dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('drag-over'); +}); + +dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('drag-over'); +}); + +dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + addFiles(e.dataTransfer.files); +}); + +fileInput.addEventListener('change', () => { + addFiles(fileInput.files); + fileInput.value = ''; +}); + +clearFilesBtn.addEventListener('click', () => { + selectedFiles = []; + updateFilesList(); +}); + +// Transcribe form submit +document.getElementById('transcribe-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (selectedFiles.length === 0) return; + + const button = transcribeBtn; + const language = document.getElementById('transcribe-lang').value; + const model = document.getElementById('transcribe-model').value; + + const transcribeProgress = document.getElementById('transcribe-progress'); + const transcribeProgressFill = document.getElementById('transcribe-progress-fill'); + const transcribeProgressTitle = document.getElementById('transcribe-progress-title'); + const transcribeProgressPercent = document.getElementById('transcribe-progress-percent'); + const transcribeProgressInfo = document.getElementById('transcribe-progress-info'); + const transcribeProgressCurrent = document.getElementById('transcribe-progress-current'); + + setLoading(button, true); + transcribeProgress.style.display = 'block'; + transcribeProgressFill.style.width = '0%'; + transcribeProgressTitle.textContent = 'Uploading and transcribing...'; + transcribeProgressPercent.textContent = '0%'; + transcribeProgressInfo.textContent = `0/${selectedFiles.length} files`; + document.getElementById('transcribe-result').classList.remove('show'); + + const formData = new FormData(); + selectedFiles.forEach(file => formData.append('files', file)); + if (language) formData.append('language', language); + formData.append('model', model); + + try { + const response = await fetch(`${API_URL}/upload-transcribe`, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.error || 'Transcription failed'); + + transcribeProgressFill.style.width = '100%'; + transcribeProgressPercent.textContent = '100%'; + transcribeProgressTitle.textContent = 'Transcription Complete!'; + transcribeProgressInfo.textContent = `${data.successCount}/${data.totalFiles} files`; + + showResult('transcribe-result', true, ` +

    Transcription Complete!

    +

    ${data.successCount}/${data.totalFiles} files transcribed

    + + ${data.results[0]?.text ? ` +

    Preview (first file):

    +
    ${data.results[0].text.substring(0, 1000)}${data.results[0].text.length > 1000 ? '...' : ''}
    + ` : ''} + `); + + selectedFiles = []; + updateFilesList(); + + } catch (error) { + transcribeProgress.style.display = 'none'; + showResult('transcribe-result', false, `

    Error

    ${error.message}

    `); + } finally { + setLoading(button, false); + } +}); + +// ==================== PROCESS TAB (Download + Transcribe) ==================== + +const processProgress = document.getElementById('process-progress'); +const processProgressFill = document.getElementById('process-progress-fill'); +const processProgressTitle = document.getElementById('process-progress-title'); +const processProgressPercent = document.getElementById('process-progress-percent'); +const processProgressPhase = document.getElementById('process-progress-phase'); +const processProgressSpeed = document.getElementById('process-progress-speed'); +const processProgressCurrent = document.getElementById('process-progress-current'); +const processProgressEta = document.getElementById('process-progress-eta'); + +function resetProcessProgress() { + processProgressFill.style.width = '0%'; + processProgressPercent.textContent = '0%'; + processProgressTitle.textContent = 'Processing...'; + processProgressPhase.textContent = ''; + processProgressSpeed.textContent = ''; + processProgressCurrent.textContent = ''; + processProgressEta.textContent = ''; +} + +document.getElementById('process-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const button = e.target.querySelector('button[type="submit"]'); + const url = document.getElementById('process-url').value; + const language = document.getElementById('process-lang').value; + const model = document.getElementById('process-model').value; + const resultDiv = document.getElementById('process-result'); + + setLoading(button, true); + resetProcessProgress(); + processProgress.style.display = 'block'; + resultDiv.classList.remove('show'); + + const params = new URLSearchParams({ url }); + if (language) params.append('language', language); + params.append('model', model); + + const eventSource = new EventSource(`${API_URL}/process-stream?${params}`); + + eventSource.addEventListener('status', (e) => { + const data = JSON.parse(e.data); + processProgressTitle.textContent = data.message; + if (data.phase === 'transcribing') { + processProgressPhase.textContent = 'Transcribing'; + } + }); + + eventSource.addEventListener('info', (e) => { + const data = JSON.parse(e.data); + processProgressTitle.textContent = data.totalVideos > 1 + ? `Processing playlist: ${data.playlistTitle} (${data.totalVideos} videos)` + : `Processing: ${data.title}`; + }); + + eventSource.addEventListener('progress', (e) => { + const data = JSON.parse(e.data); + processProgressFill.style.width = `${data.percent}%`; + processProgressPercent.textContent = `${Math.round(data.percent)}%`; + processProgressPhase.textContent = data.phaseLabel || ''; + if (data.speed) processProgressSpeed.textContent = data.speed; + if (data.title) { + processProgressCurrent.innerHTML = `${data.phaseLabel}: ${data.title}`; + } + if (data.totalVideos > 1) { + processProgressCurrent.innerHTML += ` (${data.currentVideo}/${data.totalVideos})`; + } + }); + + eventSource.addEventListener('video-complete', (e) => { + const data = JSON.parse(e.data); + processProgressCurrent.innerHTML = `Downloaded: ${data.title}`; + }); + + eventSource.addEventListener('transcribe-complete', (e) => { + const data = JSON.parse(e.data); + processProgressCurrent.innerHTML = `Transcribed: ${data.title} (${data.videosCompleted}/${data.totalFiles})`; + }); + + eventSource.addEventListener('complete', (e) => { + const data = JSON.parse(e.data); + eventSource.close(); + processProgressFill.style.width = '100%'; + processProgressPercent.textContent = '100%'; + processProgressTitle.textContent = 'Processing Complete!'; + processProgressPhase.textContent = ''; + processProgressEta.textContent = `Total: ${formatTime(data.totalTime)}`; + + showResult('process-result', true, ` +

    Processing Complete!

    + ${data.playlistTitle ? `

    Playlist: ${data.playlistTitle}

    ` : ''} +

    Downloaded: ${data.downloadedCount}/${data.totalVideos}

    +

    Transcribed: ${data.transcribedCount}/${data.totalVideos}

    + + ${data.results[0]?.text ? ` +

    Preview (first file):

    +
    ${data.results[0].text.substring(0, 1000)}${data.results[0].text.length > 1000 ? '...' : ''}
    + ` : ''} + `); + setLoading(button, false); + }); + + eventSource.addEventListener('error', (e) => { + let errorMsg = 'Processing failed'; + try { errorMsg = JSON.parse(e.data).message || errorMsg; } catch {} + eventSource.close(); + processProgress.style.display = 'none'; + showResult('process-result', false, `

    Error

    ${errorMsg}

    `); + setLoading(button, false); + }); + + eventSource.onerror = () => { + eventSource.close(); + processProgress.style.display = 'none'; + showResult('process-result', false, `

    Error

    Connection lost

    `); + setLoading(button, false); + }; +}); + +// ==================== TRANSLATE CHECKBOXES (Transcribe & Process tabs) ==================== + +// Transcribe tab checkbox +const transcribeTranslateCheckbox = document.getElementById('transcribe-translate'); +const transcribeTranslateLang = document.getElementById('transcribe-translate-lang'); + +transcribeTranslateCheckbox.addEventListener('change', () => { + transcribeTranslateLang.disabled = !transcribeTranslateCheckbox.checked; +}); + +// Process tab checkbox +const processTranslateCheckbox = document.getElementById('process-translate'); +const processTranslateLang = document.getElementById('process-translate-lang'); + +processTranslateCheckbox.addEventListener('change', () => { + processTranslateLang.disabled = !processTranslateCheckbox.checked; +}); + +// ==================== TRANSLATE TAB ==================== + +// Mode switching +const translateModeBtns = document.querySelectorAll('.mode-btn'); +const translateTextMode = document.getElementById('translate-text-mode'); +const translateFileMode = document.getElementById('translate-file-mode'); + +translateModeBtns.forEach(btn => { + btn.addEventListener('click', () => { + translateModeBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + if (btn.dataset.mode === 'text') { + translateTextMode.style.display = 'block'; + translateFileMode.style.display = 'none'; + } else { + translateTextMode.style.display = 'none'; + translateFileMode.style.display = 'block'; + } + }); +}); + +// Text translation form +document.getElementById('translate-text-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const button = document.getElementById('translate-text-btn'); + const text = document.getElementById('translate-input').value; + const sourceLang = document.getElementById('translate-source').value; + const targetLang = document.getElementById('translate-target').value; + + if (!text.trim()) { + showResult('translate-text-result', false, '

    Error

    Please enter text to translate

    '); + return; + } + + setLoading(button, true); + document.getElementById('translate-text-result').classList.remove('show'); + + try { + const response = await fetch(`${API_URL}/translate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, targetLang, sourceLang: sourceLang || null }) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Translation failed'); + + showResult('translate-text-result', true, ` +

    Translation Complete!

    +

    From: ${data.sourceLanguage} To: ${data.targetLanguage}

    +
    ${data.translatedText}
    + `); + } catch (error) { + showResult('translate-text-result', false, `

    Error

    ${error.message}

    `); + } finally { + setLoading(button, false); + } +}); + +// File translation - Drag & Drop +let translateSelectedFiles = []; +const translateDropZone = document.getElementById('translate-drop-zone'); +const translateFileInput = document.getElementById('translate-file-input'); +const translateSelectedFilesDiv = document.getElementById('translate-selected-files'); +const translateFilesList = document.getElementById('translate-files-list'); +const translateFileBtn = document.getElementById('translate-file-btn'); +const translateClearFilesBtn = document.getElementById('translate-clear-files'); + +function updateTranslateFilesList() { + if (translateSelectedFiles.length === 0) { + translateSelectedFilesDiv.style.display = 'none'; + translateFileBtn.disabled = true; + return; + } + + translateSelectedFilesDiv.style.display = 'block'; + translateFileBtn.disabled = false; + + translateFilesList.innerHTML = translateSelectedFiles.map((file, index) => ` +
  • + ${file.name} + ${formatSize(file.size)} + +
  • + `).join(''); + + translateFilesList.querySelectorAll('.remove-file').forEach(btn => { + btn.addEventListener('click', () => { + translateSelectedFiles.splice(parseInt(btn.dataset.index), 1); + updateTranslateFilesList(); + }); + }); +} + +function addTranslateFiles(files) { + const textFiles = Array.from(files).filter(f => + f.type === 'text/plain' || f.name.endsWith('.txt') + ); + translateSelectedFiles = [...translateSelectedFiles, ...textFiles]; + updateTranslateFilesList(); +} + +translateDropZone.addEventListener('click', () => translateFileInput.click()); + +translateDropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + translateDropZone.classList.add('drag-over'); +}); + +translateDropZone.addEventListener('dragleave', () => { + translateDropZone.classList.remove('drag-over'); +}); + +translateDropZone.addEventListener('drop', (e) => { + e.preventDefault(); + translateDropZone.classList.remove('drag-over'); + addTranslateFiles(e.dataTransfer.files); +}); + +translateFileInput.addEventListener('change', () => { + addTranslateFiles(translateFileInput.files); + translateFileInput.value = ''; +}); + +translateClearFilesBtn.addEventListener('click', () => { + translateSelectedFiles = []; + updateTranslateFilesList(); +}); + +// File translation form submit +document.getElementById('translate-file-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (translateSelectedFiles.length === 0) return; + + const button = translateFileBtn; + const sourceLang = document.getElementById('translate-file-source').value; + const targetLang = document.getElementById('translate-file-target').value; + + setLoading(button, true); + document.getElementById('translate-file-result').classList.remove('show'); + + const formData = new FormData(); + translateSelectedFiles.forEach(file => formData.append('files', file)); + formData.append('targetLang', targetLang); + if (sourceLang) formData.append('sourceLang', sourceLang); + + try { + const response = await fetch(`${API_URL}/translate-file`, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Translation failed'); + + showResult('translate-file-result', true, ` +

    Translation Complete!

    +

    ${data.successCount}/${data.totalFiles} files translated

    + + ${data.results[0]?.translatedText ? ` +

    Preview (first file):

    +
    ${data.results[0].translatedText.substring(0, 1000)}${data.results[0].translatedText.length > 1000 ? '...' : ''}
    + ` : ''} + `); + + translateSelectedFiles = []; + updateTranslateFilesList(); + + } catch (error) { + showResult('translate-file-result', false, `

    Error

    ${error.message}

    `); + } finally { + 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, '

    Error

    Please enter text to summarize

    '); + 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, ` +

    Summary Complete!

    +

    Model: ${data.model} | Style: ${data.style} | Chunks: ${data.chunks}

    +
    ${data.summary}
    + `); + } catch (error) { + showResult('summarize-text-result', false, `

    Error

    ${error.message}

    `); + } 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) => ` +
  • + ${file.name} + ${formatSize(file.size)} + +
  • + `).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, ` +

    Summarization Complete!

    +

    ${data.successCount}/${data.totalFiles} files summarized

    + + ${data.results[0]?.summary ? ` +

    Preview (first file):

    +
    ${data.results[0].summary.substring(0, 1000)}${data.results[0].summary.length > 1000 ? '...' : ''}
    + ` : ''} + `); + + summarizeSelectedFiles = []; + updateSummarizeFilesList(); + + } catch (error) { + showResult('summarize-file-result', false, `

    Error

    ${error.message}

    `); + } 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}: ${data.title}`; + } + if (data.totalVideos > 1) { + linkProgressCurrent.innerHTML += ` (${data.currentVideo}/${data.totalVideos})`; + } + }); + + eventSource.addEventListener('video-complete', (e) => { + const data = JSON.parse(e.data); + linkProgressCurrent.innerHTML = `Downloaded: ${data.title}`; + }); + + eventSource.addEventListener('transcribe-complete', (e) => { + const data = JSON.parse(e.data); + linkProgressCurrent.innerHTML = `Transcribed: ${data.title}`; + }); + + eventSource.addEventListener('summarize-complete', (e) => { + const data = JSON.parse(e.data); + linkProgressCurrent.innerHTML = `Summarized: ${data.title}`; + }); + + 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, ` +

    Pipeline Complete!

    + ${data.playlistTitle ? `

    Playlist: ${data.playlistTitle}

    ` : ''} +

    Downloaded: ${data.downloadedCount}/${data.totalVideos} | Transcribed: ${data.transcribedCount} | Summarized: ${data.summarizedCount}

    + + ${data.results[0]?.summary ? ` +

    Summary Preview:

    +
    ${data.results[0].summary.substring(0, 2000)}${data.results[0].summary.length > 2000 ? '...' : ''}
    + ` : ''} + `); + 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, `

    Error

    ${errorMsg}

    `); + setLoading(button, false); + }); + + eventSource.onerror = () => { + eventSource.close(); + linkProgress.style.display = 'none'; + showResult('summarize-link-result', false, `

    Error

    Connection lost

    `); + setLoading(button, false); + }; + }); +} diff --git a/public/index.html b/public/index.html index 4c009c7..43b9ed7 100644 --- a/public/index.html +++ b/public/index.html @@ -1,577 +1,577 @@ - - - - - - Video to MP3 Transcriptor - - - -
    -
    -

    Video to MP3 Transcriptor

    -

    Download YouTube videos, transcribe and translate them

    -
    - - - - - -
    -

    Download YouTube Video/Playlist

    -
    -
    - - -
    - -
    - -
    -
    - - -
    -

    Convert Video/Audio to MP3

    -
    -
    -
    - - - - - -
    -

    Drag & drop video/audio files here

    -

    or click to select files

    -

    Supported: MP4, M4A, AVI, MKV, MOV, WAV, FLAC, OGG

    - -
    -
    - -
    -
    -
    - - -
    -
    - - -
    -
    - -
    - -
    -
    - - -
    -

    Transcribe Audio File

    -
    -
    -
    - - - - - -
    -

    Drag & drop audio files here

    -

    or click to select files

    - -
    -
    - -
    -
    -
    - - -
    -
    - - -
    -
    - -
    - - -
    - -
    - -
    -
    - - -
    -

    Download + Transcribe

    -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - -
    - - -
    - -
    - -
    -
    - - -
    -

    Translate Text

    - - -
    - - -
    - - -
    -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - -
    -
    -
    - - - -
    - - -
    -

    Summarize Text (GPT-5.1)

    - - -
    - - - -
    - - -
    -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - -
    -
    -
    - - - - - - -
    -
    - - - - + + + + + + Video to MP3 Transcriptor + + + +
    +
    +

    Video to MP3 Transcriptor

    +

    Download YouTube videos, transcribe and translate them

    +
    + + + + + +
    +

    Download YouTube Video/Playlist

    +
    +
    + + +
    + +
    + +
    +
    + + +
    +

    Convert Video/Audio to MP3

    +
    +
    +
    + + + + + +
    +

    Drag & drop video/audio files here

    +

    or click to select files

    +

    Supported: MP4, M4A, AVI, MKV, MOV, WAV, FLAC, OGG

    + +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    + + +
    +

    Transcribe Audio File

    +
    +
    +
    + + + + + +
    +

    Drag & drop audio files here

    +

    or click to select files

    + +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + +
    +
    + + +
    +

    Download + Transcribe

    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + +
    +
    + + +
    +

    Translate Text

    + + +
    + + +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + + +
    + + +
    +

    Summarize Text (GPT-5.1)

    + + +
    + + + +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + + + + + +
    +
    + + + + diff --git a/public/style.css b/public/style.css index 9d5d9e1..d9b3f34 100644 --- a/public/style.css +++ b/public/style.css @@ -1,716 +1,716 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); - min-height: 100vh; - color: #fff; -} - -.container { - max-width: 900px; - margin: 0 auto; - padding: 2rem; -} - -header { - text-align: center; - margin-bottom: 2rem; -} - -header h1 { - font-size: 2.5rem; - background: linear-gradient(90deg, #e94560, #0f3460); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.subtitle { - color: #8892b0; - margin-top: 0.5rem; -} - -/* Tabs */ -.tabs { - display: flex; - gap: 0.5rem; - margin-bottom: 2rem; - border-bottom: 2px solid #233554; - padding-bottom: 0.5rem; - flex-wrap: wrap; -} - -.tab { - padding: 0.75rem 1.5rem; - background: transparent; - border: none; - color: #8892b0; - cursor: pointer; - font-size: 1rem; - border-radius: 8px 8px 0 0; - transition: all 0.3s ease; -} - -.tab:hover { - color: #e94560; - background: rgba(233, 69, 96, 0.1); -} - -.tab.active { - color: #e94560; - background: rgba(233, 69, 96, 0.2); - border-bottom: 2px solid #e94560; - margin-bottom: -2px; -} - -/* Tab Content */ -.tab-content { - display: none; - background: rgba(255, 255, 255, 0.05); - padding: 2rem; - border-radius: 12px; - backdrop-filter: blur(10px); -} - -.tab-content.active { - display: block; - animation: fadeIn 0.3s ease; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -.tab-content h2 { - margin-bottom: 1.5rem; - color: #ccd6f6; -} - -/* Forms */ -.form-group { - margin-bottom: 1.5rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - color: #8892b0; - font-size: 0.9rem; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - -@media (max-width: 600px) { - .form-row { - grid-template-columns: 1fr; - } -} - -input[type="url"], -input[type="text"], -select { - width: 100%; - padding: 0.75rem 1rem; - background: rgba(255, 255, 255, 0.1); - border: 1px solid #233554; - border-radius: 8px; - color: #fff; - font-size: 1rem; - transition: all 0.3s ease; -} - -input[type="url"]:focus, -input[type="text"]:focus, -select:focus { - outline: none; - border-color: #e94560; - background: rgba(233, 69, 96, 0.1); -} - -select option { - background: #1a1a2e; - color: #fff; -} - -/* Buttons */ -.btn { - padding: 0.75rem 2rem; - border: none; - border-radius: 8px; - font-size: 1rem; - cursor: pointer; - transition: all 0.3s ease; - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.btn-primary { - background: linear-gradient(90deg, #e94560, #0f3460); - color: #fff; -} - -.btn-primary:hover { - transform: translateY(-2px); - box-shadow: 0 5px 20px rgba(233, 69, 96, 0.4); -} - -.btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.btn-secondary { - background: rgba(255, 255, 255, 0.1); - color: #ccd6f6; - border: 1px solid #233554; -} - -.btn-secondary:hover { - background: rgba(255, 255, 255, 0.2); -} - -.btn-small { - padding: 0.5rem 1rem; - font-size: 0.85rem; - margin-left: 0.5rem; -} - -.btn-loading { - display: none; -} - -.btn.loading .btn-text { - display: none; -} - -.btn.loading .btn-loading { - display: inline; -} - -/* Results */ -.result { - margin-top: 1.5rem; - padding: 1rem; - border-radius: 8px; - display: none; -} - -.result.show { - display: block; - animation: fadeIn 0.3s ease; -} - -.result.success { - background: rgba(16, 185, 129, 0.2); - border: 1px solid #10b981; -} - -.result.error { - background: rgba(239, 68, 68, 0.2); - border: 1px solid #ef4444; -} - -.result h3 { - margin-bottom: 0.75rem; - font-size: 1.1rem; -} - -.result ul { - list-style: none; - margin-top: 0.5rem; -} - -.result li { - padding: 0.5rem 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.result li:last-child { - border-bottom: none; -} - -.result .icon-success { - color: #10b981; -} - -.result .icon-error { - color: #ef4444; -} - -.result a { - color: #e94560; - text-decoration: none; -} - -.result a:hover { - text-decoration: underline; -} - -.result .preview { - margin-top: 1rem; - padding: 1rem; - background: rgba(0, 0, 0, 0.3); - border-radius: 8px; - font-family: monospace; - font-size: 0.9rem; - white-space: pre-wrap; - max-height: 300px; - overflow-y: auto; -} - -/* Files Grid */ -.files-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 1rem; - margin-top: 1.5rem; -} - -.file-card { - background: rgba(255, 255, 255, 0.05); - padding: 1rem; - border-radius: 8px; - border: 1px solid #233554; - transition: all 0.3s ease; -} - -.file-card:hover { - border-color: #e94560; - transform: translateY(-2px); -} - -.file-card .file-name { - font-weight: 600; - margin-bottom: 0.5rem; - word-break: break-all; - color: #ccd6f6; -} - -.file-card .file-type { - font-size: 0.8rem; - color: #8892b0; - margin-bottom: 0.75rem; -} - -.file-card .file-actions { - display: flex; - gap: 0.5rem; -} - -.file-card .file-actions a { - padding: 0.4rem 0.8rem; - background: rgba(233, 69, 96, 0.2); - color: #e94560; - text-decoration: none; - border-radius: 4px; - font-size: 0.85rem; - transition: all 0.3s ease; -} - -.file-card .file-actions a:hover { - background: #e94560; - color: #fff; -} - -/* Loading spinner */ -@keyframes spin { - to { transform: rotate(360deg); } -} - -.spinner { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid rgba(255, 255, 255, 0.3); - border-top-color: #fff; - border-radius: 50%; - animation: spin 0.8s linear infinite; - margin-right: 0.5rem; -} - -/* Status indicator */ -.status { - display: inline-block; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.8rem; - margin-left: 0.5rem; -} - -.status.downloading { - background: rgba(59, 130, 246, 0.2); - color: #3b82f6; -} - -.status.transcribing { - background: rgba(168, 85, 247, 0.2); - color: #a855f7; -} - -.status.complete { - background: rgba(16, 185, 129, 0.2); - color: #10b981; -} - -/* Empty state */ -.empty-state { - text-align: center; - padding: 3rem; - color: #8892b0; -} - -.empty-state p { - margin-top: 1rem; -} - -/* Progress Bar */ -.progress-container { - margin-top: 1.5rem; - padding: 1.5rem; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - border: 1px solid #233554; -} - -.progress-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.progress-header #progress-title { - font-weight: 600; - color: #ccd6f6; - font-size: 1rem; -} - -.progress-header #progress-eta { - color: #e94560; - font-size: 0.9rem; - font-weight: 500; -} - -.progress-bar { - width: 100%; - height: 12px; - background: rgba(255, 255, 255, 0.1); - border-radius: 6px; - overflow: hidden; - position: relative; -} - -.progress-fill { - height: 100%; - background: linear-gradient(90deg, #e94560, #ff6b8a); - border-radius: 6px; - width: 0%; - transition: width 0.3s ease; - position: relative; -} - -.progress-fill::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.3), - transparent - ); - animation: shimmer 2s infinite; -} - -@keyframes shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } -} - -.progress-details { - display: flex; - justify-content: space-between; - margin-top: 0.75rem; - font-size: 0.85rem; - color: #8892b0; -} - -.progress-details #progress-percent { - color: #ccd6f6; - font-weight: 600; -} - -.progress-details #progress-speed { - color: #10b981; -} - -.progress-current { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); - font-size: 0.9rem; - color: #8892b0; -} - -.progress-current .video-title { - color: #ccd6f6; - font-weight: 500; -} - -/* Drag and Drop Zone */ -.drop-zone { - border: 2px dashed #233554; - border-radius: 12px; - padding: 3rem 2rem; - text-align: center; - cursor: pointer; - transition: all 0.3s ease; - margin-bottom: 1.5rem; - background: rgba(255, 255, 255, 0.02); -} - -.drop-zone:hover { - border-color: #e94560; - background: rgba(233, 69, 96, 0.05); -} - -.drop-zone.drag-over { - border-color: #e94560; - background: rgba(233, 69, 96, 0.1); - transform: scale(1.02); -} - -.drop-zone-icon { - color: #8892b0; - margin-bottom: 1rem; - transition: color 0.3s ease; -} - -.drop-zone:hover .drop-zone-icon { - color: #e94560; -} - -.drop-zone-text { - color: #ccd6f6; - font-size: 1.1rem; - margin-bottom: 0.5rem; -} - -.drop-zone-hint { - color: #8892b0; - font-size: 0.9rem; -} - -.drop-zone-formats { - color: #64748b; - font-size: 0.75rem; - margin-top: 0.5rem; - font-style: italic; -} - -/* Selected Files List */ -.selected-files { - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - padding: 1rem; - margin-bottom: 1.5rem; -} - -.selected-files h3 { - font-size: 0.9rem; - color: #8892b0; - margin-bottom: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.selected-files ul { - list-style: none; - margin-bottom: 1rem; -} - -.selected-files li { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - color: #ccd6f6; -} - -.selected-files li:last-child { - border-bottom: none; -} - -.selected-files .file-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.selected-files .file-size { - color: #8892b0; - font-size: 0.85rem; - margin-left: 1rem; -} - -.selected-files .remove-file { - background: none; - border: none; - color: #ef4444; - cursor: pointer; - padding: 0.25rem; - margin-left: 0.5rem; - font-size: 1.2rem; - line-height: 1; -} - -.selected-files .remove-file:hover { - color: #ff6b6b; -} - -/* Checkbox Group */ -.checkbox-group { - margin-bottom: 1rem; -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - color: #ccd6f6; - font-size: 0.95rem; -} - -.checkbox-label input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: #e94560; - cursor: pointer; -} - -.translate-lang-select { - margin-top: 0.75rem; - width: auto; - min-width: 150px; -} - -.translate-lang-select:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Translate Tab */ -.translate-mode-selector { - display: flex; - gap: 0.5rem; - margin-bottom: 1.5rem; -} - -.mode-btn { - padding: 0.6rem 1.25rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid #233554; - color: #8892b0; - border-radius: 8px; - cursor: pointer; - font-size: 0.95rem; - transition: all 0.3s ease; -} - -.mode-btn:hover { - border-color: #e94560; - color: #ccd6f6; -} - -.mode-btn.active { - background: rgba(233, 69, 96, 0.2); - border-color: #e94560; - color: #e94560; -} - -.translate-mode { - animation: fadeIn 0.3s ease; -} - -/* Textarea */ -textarea { - width: 100%; - padding: 1rem; - background: rgba(255, 255, 255, 0.1); - border: 1px solid #233554; - border-radius: 8px; - color: #fff; - font-size: 1rem; - font-family: inherit; - resize: vertical; - min-height: 150px; - transition: all 0.3s ease; -} - -textarea:focus { - outline: none; - border-color: #e94560; - background: rgba(233, 69, 96, 0.1); -} - -textarea::placeholder { - color: #8892b0; -} - -/* Translation Result */ -.translation-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; -} - -/* 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; -} +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + min-height: 100vh; + color: #fff; +} + +.container { + max-width: 900px; + margin: 0 auto; + padding: 2rem; +} + +header { + text-align: center; + margin-bottom: 2rem; +} + +header h1 { + font-size: 2.5rem; + background: linear-gradient(90deg, #e94560, #0f3460); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.subtitle { + color: #8892b0; + margin-top: 0.5rem; +} + +/* Tabs */ +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + border-bottom: 2px solid #233554; + padding-bottom: 0.5rem; + flex-wrap: wrap; +} + +.tab { + padding: 0.75rem 1.5rem; + background: transparent; + border: none; + color: #8892b0; + cursor: pointer; + font-size: 1rem; + border-radius: 8px 8px 0 0; + transition: all 0.3s ease; +} + +.tab:hover { + color: #e94560; + background: rgba(233, 69, 96, 0.1); +} + +.tab.active { + color: #e94560; + background: rgba(233, 69, 96, 0.2); + border-bottom: 2px solid #e94560; + margin-bottom: -2px; +} + +/* Tab Content */ +.tab-content { + display: none; + background: rgba(255, 255, 255, 0.05); + padding: 2rem; + border-radius: 12px; + backdrop-filter: blur(10px); +} + +.tab-content.active { + display: block; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.tab-content h2 { + margin-bottom: 1.5rem; + color: #ccd6f6; +} + +/* Forms */ +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #8892b0; + font-size: 0.9rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media (max-width: 600px) { + .form-row { + grid-template-columns: 1fr; + } +} + +input[type="url"], +input[type="text"], +select { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid #233554; + border-radius: 8px; + color: #fff; + font-size: 1rem; + transition: all 0.3s ease; +} + +input[type="url"]:focus, +input[type="text"]:focus, +select:focus { + outline: none; + border-color: #e94560; + background: rgba(233, 69, 96, 0.1); +} + +select option { + background: #1a1a2e; + color: #fff; +} + +/* Buttons */ +.btn { + padding: 0.75rem 2rem; + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-primary { + background: linear-gradient(90deg, #e94560, #0f3460); + color: #fff; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(233, 69, 96, 0.4); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: #ccd6f6; + border: 1px solid #233554; +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); +} + +.btn-small { + padding: 0.5rem 1rem; + font-size: 0.85rem; + margin-left: 0.5rem; +} + +.btn-loading { + display: none; +} + +.btn.loading .btn-text { + display: none; +} + +.btn.loading .btn-loading { + display: inline; +} + +/* Results */ +.result { + margin-top: 1.5rem; + padding: 1rem; + border-radius: 8px; + display: none; +} + +.result.show { + display: block; + animation: fadeIn 0.3s ease; +} + +.result.success { + background: rgba(16, 185, 129, 0.2); + border: 1px solid #10b981; +} + +.result.error { + background: rgba(239, 68, 68, 0.2); + border: 1px solid #ef4444; +} + +.result h3 { + margin-bottom: 0.75rem; + font-size: 1.1rem; +} + +.result ul { + list-style: none; + margin-top: 0.5rem; +} + +.result li { + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.result li:last-child { + border-bottom: none; +} + +.result .icon-success { + color: #10b981; +} + +.result .icon-error { + color: #ef4444; +} + +.result a { + color: #e94560; + text-decoration: none; +} + +.result a:hover { + text-decoration: underline; +} + +.result .preview { + margin-top: 1rem; + padding: 1rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + font-family: monospace; + font-size: 0.9rem; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +/* Files Grid */ +.files-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.file-card { + background: rgba(255, 255, 255, 0.05); + padding: 1rem; + border-radius: 8px; + border: 1px solid #233554; + transition: all 0.3s ease; +} + +.file-card:hover { + border-color: #e94560; + transform: translateY(-2px); +} + +.file-card .file-name { + font-weight: 600; + margin-bottom: 0.5rem; + word-break: break-all; + color: #ccd6f6; +} + +.file-card .file-type { + font-size: 0.8rem; + color: #8892b0; + margin-bottom: 0.75rem; +} + +.file-card .file-actions { + display: flex; + gap: 0.5rem; +} + +.file-card .file-actions a { + padding: 0.4rem 0.8rem; + background: rgba(233, 69, 96, 0.2); + color: #e94560; + text-decoration: none; + border-radius: 4px; + font-size: 0.85rem; + transition: all 0.3s ease; +} + +.file-card .file-actions a:hover { + background: #e94560; + color: #fff; +} + +/* Loading spinner */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 0.5rem; +} + +/* Status indicator */ +.status { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + margin-left: 0.5rem; +} + +.status.downloading { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.status.transcribing { + background: rgba(168, 85, 247, 0.2); + color: #a855f7; +} + +.status.complete { + background: rgba(16, 185, 129, 0.2); + color: #10b981; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 3rem; + color: #8892b0; +} + +.empty-state p { + margin-top: 1rem; +} + +/* Progress Bar */ +.progress-container { + margin-top: 1.5rem; + padding: 1.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + border: 1px solid #233554; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.progress-header #progress-title { + font-weight: 600; + color: #ccd6f6; + font-size: 1rem; +} + +.progress-header #progress-eta { + color: #e94560; + font-size: 0.9rem; + font-weight: 500; +} + +.progress-bar { + width: 100%; + height: 12px; + background: rgba(255, 255, 255, 0.1); + border-radius: 6px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #e94560, #ff6b8a); + border-radius: 6px; + width: 0%; + transition: width 0.3s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.3), + transparent + ); + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.progress-details { + display: flex; + justify-content: space-between; + margin-top: 0.75rem; + font-size: 0.85rem; + color: #8892b0; +} + +.progress-details #progress-percent { + color: #ccd6f6; + font-weight: 600; +} + +.progress-details #progress-speed { + color: #10b981; +} + +.progress-current { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 0.9rem; + color: #8892b0; +} + +.progress-current .video-title { + color: #ccd6f6; + font-weight: 500; +} + +/* Drag and Drop Zone */ +.drop-zone { + border: 2px dashed #233554; + border-radius: 12px; + padding: 3rem 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + margin-bottom: 1.5rem; + background: rgba(255, 255, 255, 0.02); +} + +.drop-zone:hover { + border-color: #e94560; + background: rgba(233, 69, 96, 0.05); +} + +.drop-zone.drag-over { + border-color: #e94560; + background: rgba(233, 69, 96, 0.1); + transform: scale(1.02); +} + +.drop-zone-icon { + color: #8892b0; + margin-bottom: 1rem; + transition: color 0.3s ease; +} + +.drop-zone:hover .drop-zone-icon { + color: #e94560; +} + +.drop-zone-text { + color: #ccd6f6; + font-size: 1.1rem; + margin-bottom: 0.5rem; +} + +.drop-zone-hint { + color: #8892b0; + font-size: 0.9rem; +} + +.drop-zone-formats { + color: #64748b; + font-size: 0.75rem; + margin-top: 0.5rem; + font-style: italic; +} + +/* Selected Files List */ +.selected-files { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.selected-files h3 { + font-size: 0.9rem; + color: #8892b0; + margin-bottom: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.selected-files ul { + list-style: none; + margin-bottom: 1rem; +} + +.selected-files li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #ccd6f6; +} + +.selected-files li:last-child { + border-bottom: none; +} + +.selected-files .file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selected-files .file-size { + color: #8892b0; + font-size: 0.85rem; + margin-left: 1rem; +} + +.selected-files .remove-file { + background: none; + border: none; + color: #ef4444; + cursor: pointer; + padding: 0.25rem; + margin-left: 0.5rem; + font-size: 1.2rem; + line-height: 1; +} + +.selected-files .remove-file:hover { + color: #ff6b6b; +} + +/* Checkbox Group */ +.checkbox-group { + margin-bottom: 1rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + color: #ccd6f6; + font-size: 0.95rem; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: #e94560; + cursor: pointer; +} + +.translate-lang-select { + margin-top: 0.75rem; + width: auto; + min-width: 150px; +} + +.translate-lang-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Translate Tab */ +.translate-mode-selector { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.mode-btn { + padding: 0.6rem 1.25rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid #233554; + color: #8892b0; + border-radius: 8px; + cursor: pointer; + font-size: 0.95rem; + transition: all 0.3s ease; +} + +.mode-btn:hover { + border-color: #e94560; + color: #ccd6f6; +} + +.mode-btn.active { + background: rgba(233, 69, 96, 0.2); + border-color: #e94560; + color: #e94560; +} + +.translate-mode { + animation: fadeIn 0.3s ease; +} + +/* Textarea */ +textarea { + width: 100%; + padding: 1rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid #233554; + border-radius: 8px; + color: #fff; + font-size: 1rem; + font-family: inherit; + resize: vertical; + min-height: 150px; + transition: all 0.3s ease; +} + +textarea:focus { + outline: none; + border-color: #e94560; + background: rgba(233, 69, 96, 0.1); +} + +textarea::placeholder { + color: #8892b0; +} + +/* Translation Result */ +.translation-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; +} + +/* 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; +} diff --git a/src/server.js b/src/server.js index f16ed6d..ec7bf67 100644 --- a/src/server.js +++ b/src/server.js @@ -1,1137 +1,1137 @@ -import express from 'express'; -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'; -import { convertToMP3, convertMultipleToMP3, getSupportedFormats } from './services/conversion.js'; - -dotenv.config(); - -const app = express(); -const PORT = process.env.PORT || 8888; -const OUTPUT_DIR = process.env.OUTPUT_DIR || './output'; - -// Ensure output directory exists -if (!fs.existsSync(OUTPUT_DIR)) { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); -} - -// Configure multer for file uploads -const storage = multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, OUTPUT_DIR); - }, - filename: (req, file, cb) => { - // Keep original filename but sanitize it - const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); - cb(null, safeName); - } -}); - -const upload = multer({ - storage, - fileFilter: (req, file, cb) => { - const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/m4a', 'audio/ogg', 'audio/flac', 'audio/x-m4a']; - if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|m4a|ogg|flac)$/i)) { - cb(null, true); - } else { - cb(new Error('Invalid file type. Only audio files are allowed.')); - } - } -}); - -// Upload handler for text files (for translation) -const uploadText = multer({ - storage, - fileFilter: (req, file, cb) => { - if (file.mimetype === 'text/plain' || file.originalname.endsWith('.txt')) { - cb(null, true); - } else { - cb(new Error('Invalid file type. Only text files (.txt) are allowed.')); - } - } -}); - -// Upload handler for video/audio files (for conversion) -const uploadVideo = multer({ - storage, - fileFilter: (req, file, cb) => { - const videoTypes = ['video/mp4', 'video/avi', 'video/x-msvideo', 'video/quicktime', 'video/x-matroska', 'video/webm']; - const audioTypes = ['audio/m4a', 'audio/x-m4a', 'audio/wav', 'audio/flac', 'audio/ogg', 'audio/aac']; - const videoExtensions = /\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v|m4a|wav|flac|ogg|aac)$/i; - - if (videoTypes.includes(file.mimetype) || audioTypes.includes(file.mimetype) || file.originalname.match(videoExtensions)) { - cb(null, true); - } else { - cb(new Error('Invalid file type. Only video/audio files are allowed.')); - } - } -}); - -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 __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -app.use(express.static(path.join(__dirname, '../public'))); - -// Serve downloaded files -app.use('/files', express.static(OUTPUT_DIR)); - -// API info endpoint -app.get('/api', (req, res) => { - res.json({ - name: 'Video to MP3 Transcriptor API', - version: '1.0.0', - endpoints: { - 'GET /health': 'Health check', - 'GET /info?url=': 'Get video/playlist info', - 'POST /download': 'Download as MP3', - 'POST /transcribe': 'Transcribe audio file', - 'POST /process': 'Download + transcribe', - 'GET /files-list': 'List downloaded files', - 'GET /files/': 'Serve downloaded files', - }, - }); -}); - -// Health check -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -/** - * GET /info?url= - * Get info about a video or playlist - */ -app.get('/info', async (req, res) => { - try { - const { url } = req.query; - - if (!url) { - return res.status(400).json({ error: 'URL parameter required' }); - } - - // Check if URL contains playlist parameter - const hasPlaylist = url.includes('list='); - const info = await getInfo(url, hasPlaylist); - - res.json({ - success: true, - title: info.title, - type: info._type || 'video', - duration: info.duration, - channel: info.channel, - entries: info._type === 'playlist' - ? info.entries?.map(e => ({ id: e.id, title: e.title })) - : null, - videoCount: info._type === 'playlist' ? info.entries?.length : 1, - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -/** - * GET /download-stream - * Download with SSE progress updates - * Query: url (required), outputPath (optional) - */ -app.get('/download-stream', async (req, res) => { - const { url, outputPath } = req.query; - - if (!url) { - return res.status(400).json({ error: 'URL parameter required' }); - } - - // 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`); - }; - - // Track timing for estimation - const startTime = Date.now(); - let videosCompleted = 0; - let totalVideos = 1; - const videoTimes = []; - - try { - // First, get info to know total videos - sendEvent('status', { message: 'Fetching video info...', phase: 'info' }); - 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, - }); - - console.log(`Downloading: ${url}`); - - let videoStartTime = Date.now(); - - const result = await download(url, { - outputDir: outputPath || OUTPUT_DIR, - onDownloadProgress: (progress) => { - // Calculate overall progress - const videoProgress = progress.percent || 0; - const overallPercent = ((videosCompleted + (videoProgress / 100)) / totalVideos) * 100; - - // Estimate remaining time - let estimatedRemaining = null; - if (videosCompleted > 0 && videoTimes.length > 0) { - const avgTimePerVideo = videoTimes.reduce((a, b) => a + b, 0) / videoTimes.length; - const remainingVideos = totalVideos - videosCompleted - (videoProgress / 100); - estimatedRemaining = Math.round(avgTimePerVideo * remainingVideos / 1000); - } else if (progress.eta) { - // Parse ETA from yt-dlp (format: MM:SS) - const [mins, secs] = progress.eta.split(':').map(Number); - const currentVideoRemaining = mins * 60 + secs; - const remainingVideos = totalVideos - videosCompleted - 1; - // Estimate based on current video - if (videoProgress > 10) { - const elapsed = (Date.now() - videoStartTime) / 1000; - const estimatedVideoTime = (elapsed / videoProgress) * 100; - estimatedRemaining = Math.round(currentVideoRemaining + (remainingVideos * estimatedVideoTime)); - } - } - - sendEvent('progress', { - percent: Math.round(overallPercent * 10) / 10, - videoPercent: Math.round(videoProgress * 10) / 10, - currentVideo: progress.videoIndex || 1, - totalVideos: progress.totalVideos || totalVideos, - title: progress.title, - speed: progress.speed, - eta: progress.eta, - estimatedRemaining, - phase: 'downloading', - }); - }, - onVideoComplete: (video) => { - const videoTime = Date.now() - videoStartTime; - videoTimes.push(videoTime); - videosCompleted++; - videoStartTime = Date.now(); - - sendEvent('video-complete', { - title: video.title, - success: video.success, - videosCompleted, - totalVideos, - }); - }, - }); - - // Send final result - sendEvent('complete', { - success: true, - playlistTitle: result.playlistTitle, - totalVideos: result.totalVideos, - successCount: result.successCount, - failCount: result.failCount, - totalTime: Math.round((Date.now() - startTime) / 1000), - videos: result.videos.map(v => ({ - success: v.success, - title: v.title, - filePath: v.filePath, - fileUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, - error: v.error, - })), - }); - - } catch (error) { - sendEvent('error', { message: error.message }); - } finally { - res.end(); - } -}); - -/** - * POST /download - * Download a video or playlist as MP3 (non-streaming version) - * Body: { url: string, outputPath?: string } - */ -app.post('/download', async (req, res) => { - try { - const { url, outputPath } = req.body; - const outputDir = outputPath || OUTPUT_DIR; - - if (!url) { - return res.status(400).json({ error: 'URL required in request body' }); - } - - console.log(`Downloading: ${url}`); - const result = await download(url, { outputDir }); - - res.json({ - success: true, - playlistTitle: result.playlistTitle, - totalVideos: result.totalVideos, - successCount: result.successCount, - failCount: result.failCount, - videos: result.videos.map(v => ({ - success: v.success, - title: v.title, - filePath: v.filePath, - fileUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, - error: v.error, - })), - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -/** - * POST /transcribe - * Transcribe an existing audio file - * Body: { filePath: string, language?: string, format?: string, outputPath?: string } - */ -app.post('/transcribe', async (req, res) => { - try { - const { filePath, language, format = 'txt', model = 'gpt-4o-mini-transcribe', outputPath } = req.body; - - if (!filePath) { - return res.status(400).json({ error: 'filePath required in request body' }); - } - - if (!process.env.OPENAI_API_KEY) { - return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); - } - - console.log(`Transcribing: ${filePath} with model ${model}`); - const result = await transcribeAndSave(filePath, { - language, - responseFormat: format === 'txt' ? 'text' : format, - outputFormat: format, - model, - outputDir: outputPath, - }); - - res.json({ - success: true, - filePath: result.filePath, - transcriptionPath: result.transcriptionPath, - transcriptionUrl: `/files/${path.basename(result.transcriptionPath)}`, - text: result.text, - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -/** - * POST /upload-transcribe - * Upload audio files and transcribe them - * Body: { language?: string, model?: string, outputPath?: string } - */ -app.post('/upload-transcribe', upload.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 { language, model = 'gpt-4o-mini-transcribe', outputPath } = req.body; - const results = []; - - console.log(`Transcribing ${req.files.length} uploaded files with model ${model}`); - - for (let i = 0; i < req.files.length; i++) { - const file = req.files[i]; - console.log(`[${i + 1}/${req.files.length}] Transcribing: ${file.originalname}`); - - try { - const result = await transcribeAndSave(file.path, { - language: language || undefined, - responseFormat: 'text', - outputFormat: 'txt', - model, - outputDir: outputPath, - }); - - results.push({ - success: true, - fileName: file.originalname, - filePath: file.path, - transcriptionPath: result.transcriptionPath, - transcriptionUrl: `/files/${path.basename(result.transcriptionPath)}`, - text: result.text, - }); - } catch (error) { - console.error(`Failed to transcribe ${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 }); - } -}); - -/** - * POST /convert-to-mp3 - * Upload video/audio files and convert them to MP3 - */ -app.post('/convert-to-mp3', uploadVideo.array('files', 50), async (req, res) => { - try { - if (!req.files || req.files.length === 0) { - return res.status(400).json({ error: 'No files uploaded' }); - } - - const { bitrate = '192k', quality = '2' } = req.body; - const results = []; - - console.log(`Converting ${req.files.length} files to MP3`); - - for (let i = 0; i < req.files.length; i++) { - const file = req.files[i]; - console.log(`[${i + 1}/${req.files.length}] Converting: ${file.originalname}`); - - try { - const result = await convertToMP3(file.path, { - outputDir: OUTPUT_DIR, - bitrate, - quality, - }); - - results.push({ - success: true, - fileName: file.originalname, - inputPath: file.path, - outputPath: result.outputPath, - outputUrl: `/files/${path.basename(result.outputPath)}`, - size: result.sizeHuman, - }); - } catch (error) { - console.error(`Failed to convert ${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 /supported-formats - * Get supported video/audio formats for conversion - */ -app.get('/supported-formats', (req, res) => { - res.json({ formats: getSupportedFormats() }); -}); - -/** - * GET /process-stream - * Download and transcribe with SSE progress updates - * Query: url, language?, model?, outputPath? - */ -app.get('/process-stream', async (req, res) => { - const { url, language, model = 'gpt-4o-mini-transcribe', outputPath } = 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 videosDownloaded = 0; - let videosTranscribed = 0; - let totalVideos = 1; - const videoTimes = []; - - try { - // Phase 1: Get info - sendEvent('status', { message: 'Fetching video info...', phase: 'info' }); - 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 - console.log(`Processing: ${url}`); - let videoStartTime = Date.now(); - - const downloadResult = await download(url, { - outputDir: outputPath || OUTPUT_DIR, - onDownloadProgress: (progress) => { - const videoProgress = progress.percent || 0; - // Download is 50% of total, transcribe is other 50% - const overallPercent = ((videosDownloaded + (videoProgress / 100)) / totalVideos) * 50; - - sendEvent('progress', { - percent: Math.round(overallPercent * 10) / 10, - videoPercent: Math.round(videoProgress * 10) / 10, - currentVideo: progress.videoIndex || 1, - totalVideos: progress.totalVideos || totalVideos, - title: progress.title, - speed: progress.speed, - eta: progress.eta, - phase: 'downloading', - phaseLabel: 'Downloading', - }); - }, - onVideoComplete: (video) => { - const videoTime = Date.now() - videoStartTime; - videoTimes.push(videoTime); - videosDownloaded++; - videoStartTime = Date.now(); - - sendEvent('video-complete', { - title: video.title, - success: video.success, - phase: 'downloading', - videosCompleted: videosDownloaded, - totalVideos, - }); - }, - }); - - // Phase 3: Transcribe - sendEvent('status', { message: 'Starting transcription...', phase: 'transcribing' }); - - const successfulDownloads = downloadResult.videos.filter(v => v.success); - const filePaths = successfulDownloads.map(v => v.filePath); - const transcribeResults = []; - - for (let i = 0; i < filePaths.length; i++) { - const filePath = filePaths[i]; - const video = successfulDownloads[i]; - - sendEvent('progress', { - percent: 50 + ((i / filePaths.length) * 50), - currentVideo: i + 1, - totalVideos: filePaths.length, - title: video.title, - phase: 'transcribing', - phaseLabel: 'Transcribing', - }); - - try { - const result = await transcribeAndSave(filePath, { - language: language || undefined, - responseFormat: 'text', - outputFormat: 'txt', - model, - outputDir: outputPath, - }); - transcribeResults.push(result); - videosTranscribed++; - - sendEvent('transcribe-complete', { - title: video.title, - success: true, - videosCompleted: videosTranscribed, - totalFiles: filePaths.length, - }); - } catch (error) { - transcribeResults.push({ - success: false, - filePath, - error: error.message, - }); - - sendEvent('transcribe-complete', { - title: video.title, - success: false, - error: error.message, - videosCompleted: videosTranscribed, - totalFiles: filePaths.length, - }); - } - } - - // Combine results - const combinedResults = downloadResult.videos.map(v => { - const transcription = transcribeResults.find(t => t.filePath === v.filePath); - return { - title: v.title, - downloadSuccess: v.success, - audioUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, - transcriptionSuccess: transcription?.success || false, - transcriptionUrl: transcription?.transcriptionPath - ? `/files/${path.basename(transcription.transcriptionPath)}` - : null, - text: transcription?.text, - error: v.error || transcription?.error, - }; - }); - - sendEvent('complete', { - success: true, - playlistTitle: downloadResult.playlistTitle, - totalVideos: downloadResult.totalVideos, - downloadedCount: downloadResult.successCount, - transcribedCount: videosTranscribed, - totalTime: Math.round((Date.now() - startTime) / 1000), - results: combinedResults, - }); - - } catch (error) { - sendEvent('error', { message: error.message }); - } finally { - res.end(); - } -}); - -/** - * POST /process - * Download and transcribe a video or playlist (non-streaming) - * Body: { url: string, language?: string, format?: string, outputPath?: string } - */ -app.post('/process', async (req, res) => { - try { - const { url, language, format = 'txt', outputPath, model = 'gpt-4o-mini-transcribe' } = req.body; - const outputDir = outputPath || OUTPUT_DIR; - - if (!url) { - return res.status(400).json({ error: 'URL required in request body' }); - } - - if (!process.env.OPENAI_API_KEY) { - return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); - } - - // Step 1: Download - console.log(`Step 1: Downloading ${url}`); - const downloadResult = await download(url, { outputDir }); - - // Step 2: Transcribe - console.log(`Step 2: Transcribing with model ${model}...`); - const successfulDownloads = downloadResult.videos.filter(v => v.success); - const filePaths = successfulDownloads.map(v => v.filePath); - - const transcribeResult = await transcribeMultiple(filePaths, { - language, - responseFormat: format === 'txt' ? 'text' : format, - outputFormat: format, - model, - outputDir, - }); - - // Combine results - const combinedResults = downloadResult.videos.map(v => { - const transcription = transcribeResult.results.find( - t => t.filePath === v.filePath - ); - - return { - title: v.title, - downloadSuccess: v.success, - audioPath: v.filePath, - audioUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, - transcriptionSuccess: transcription?.success || false, - transcriptionPath: transcription?.transcriptionPath, - transcriptionUrl: transcription?.transcriptionPath - ? `/files/${path.basename(transcription.transcriptionPath)}` - : null, - text: transcription?.text, - error: v.error || transcription?.error, - }; - }); - - res.json({ - success: true, - playlistTitle: downloadResult.playlistTitle, - totalVideos: downloadResult.totalVideos, - downloadedCount: downloadResult.successCount, - transcribedCount: transcribeResult.successCount, - results: combinedResults, - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -/** - * GET /files - * List all downloaded files - */ -app.get('/files-list', (req, res) => { - try { - if (!fs.existsSync(OUTPUT_DIR)) { - return res.json({ files: [] }); - } - - const files = fs.readdirSync(OUTPUT_DIR).map(file => ({ - name: file, - url: `/files/${file}`, - path: path.join(OUTPUT_DIR, file), - })); - - res.json({ files }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -/** - * GET /languages - * Get available translation languages - */ -app.get('/languages', (req, res) => { - res.json({ languages: getLanguages() }); -}); - -/** - * POST /translate - * Translate text - * Body: { text: string, targetLang: string, sourceLang?: string } - */ -app.post('/translate', async (req, res) => { - try { - const { text, targetLang, sourceLang } = req.body; - - if (!text) { - return res.status(400).json({ error: 'text required in request body' }); - } - if (!targetLang) { - return res.status(400).json({ error: 'targetLang required in request body' }); - } - - if (!process.env.OPENAI_API_KEY) { - return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); - } - - console.log(`Translating text to ${targetLang}`); - const result = await translateText(text, targetLang, sourceLang); - - res.json({ - success: true, - ...result, - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -/** - * POST /translate-file - * Translate uploaded text files - * Body: { targetLang: string, sourceLang?: string, outputPath?: string } - */ -app.post('/translate-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 { targetLang, sourceLang, outputPath } = req.body; - - if (!targetLang) { - return res.status(400).json({ error: 'targetLang required' }); - } - - const results = []; - console.log(`Translating ${req.files.length} files to ${targetLang}`); - - for (let i = 0; i < req.files.length; i++) { - const file = req.files[i]; - console.log(`[${i + 1}/${req.files.length}] Translating: ${file.originalname}`); - - try { - const result = await translateFile(file.path, targetLang, sourceLang || null, outputPath); - results.push({ - success: true, - fileName: file.originalname, - translationPath: result.translationPath, - translationUrl: `/files/${path.basename(result.translationPath)}`, - translatedText: result.translatedText, - }); - } catch (error) { - console.error(`Failed to translate ${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 /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 - * Body: { style?: string, language?: string, model?: string, outputPath?: string } - */ -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', outputPath } = 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, outputDir: outputPath }); - 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?, outputPath? - */ -app.get('/summarize-stream', async (req, res) => { - const { url, style = 'concise', language = 'same', model = 'gpt-4o-mini-transcribe', outputPath } = 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: outputPath || 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, - outputDir: outputPath, - }); - 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', outputDir: outputPath }); - 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(' 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/ - Serve downloaded files'); -}); +import express from 'express'; +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'; +import { convertToMP3, convertMultipleToMP3, getSupportedFormats } from './services/conversion.js'; + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 8888; +const OUTPUT_DIR = process.env.OUTPUT_DIR || './output'; + +// Ensure output directory exists +if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, OUTPUT_DIR); + }, + filename: (req, file, cb) => { + // Keep original filename but sanitize it + const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); + cb(null, safeName); + } +}); + +const upload = multer({ + storage, + fileFilter: (req, file, cb) => { + const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/m4a', 'audio/ogg', 'audio/flac', 'audio/x-m4a']; + if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|m4a|ogg|flac)$/i)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only audio files are allowed.')); + } + } +}); + +// Upload handler for text files (for translation) +const uploadText = multer({ + storage, + fileFilter: (req, file, cb) => { + if (file.mimetype === 'text/plain' || file.originalname.endsWith('.txt')) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only text files (.txt) are allowed.')); + } + } +}); + +// Upload handler for video/audio files (for conversion) +const uploadVideo = multer({ + storage, + fileFilter: (req, file, cb) => { + const videoTypes = ['video/mp4', 'video/avi', 'video/x-msvideo', 'video/quicktime', 'video/x-matroska', 'video/webm']; + const audioTypes = ['audio/m4a', 'audio/x-m4a', 'audio/wav', 'audio/flac', 'audio/ogg', 'audio/aac']; + const videoExtensions = /\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v|m4a|wav|flac|ogg|aac)$/i; + + if (videoTypes.includes(file.mimetype) || audioTypes.includes(file.mimetype) || file.originalname.match(videoExtensions)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only video/audio files are allowed.')); + } + } +}); + +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 __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +app.use(express.static(path.join(__dirname, '../public'))); + +// Serve downloaded files +app.use('/files', express.static(OUTPUT_DIR)); + +// API info endpoint +app.get('/api', (req, res) => { + res.json({ + name: 'Video to MP3 Transcriptor API', + version: '1.0.0', + endpoints: { + 'GET /health': 'Health check', + 'GET /info?url=': 'Get video/playlist info', + 'POST /download': 'Download as MP3', + 'POST /transcribe': 'Transcribe audio file', + 'POST /process': 'Download + transcribe', + 'GET /files-list': 'List downloaded files', + 'GET /files/': 'Serve downloaded files', + }, + }); +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +/** + * GET /info?url= + * Get info about a video or playlist + */ +app.get('/info', async (req, res) => { + try { + const { url } = req.query; + + if (!url) { + return res.status(400).json({ error: 'URL parameter required' }); + } + + // Check if URL contains playlist parameter + const hasPlaylist = url.includes('list='); + const info = await getInfo(url, hasPlaylist); + + res.json({ + success: true, + title: info.title, + type: info._type || 'video', + duration: info.duration, + channel: info.channel, + entries: info._type === 'playlist' + ? info.entries?.map(e => ({ id: e.id, title: e.title })) + : null, + videoCount: info._type === 'playlist' ? info.entries?.length : 1, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /download-stream + * Download with SSE progress updates + * Query: url (required), outputPath (optional) + */ +app.get('/download-stream', async (req, res) => { + const { url, outputPath } = req.query; + + if (!url) { + return res.status(400).json({ error: 'URL parameter required' }); + } + + // 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`); + }; + + // Track timing for estimation + const startTime = Date.now(); + let videosCompleted = 0; + let totalVideos = 1; + const videoTimes = []; + + try { + // First, get info to know total videos + sendEvent('status', { message: 'Fetching video info...', phase: 'info' }); + 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, + }); + + console.log(`Downloading: ${url}`); + + let videoStartTime = Date.now(); + + const result = await download(url, { + outputDir: outputPath || OUTPUT_DIR, + onDownloadProgress: (progress) => { + // Calculate overall progress + const videoProgress = progress.percent || 0; + const overallPercent = ((videosCompleted + (videoProgress / 100)) / totalVideos) * 100; + + // Estimate remaining time + let estimatedRemaining = null; + if (videosCompleted > 0 && videoTimes.length > 0) { + const avgTimePerVideo = videoTimes.reduce((a, b) => a + b, 0) / videoTimes.length; + const remainingVideos = totalVideos - videosCompleted - (videoProgress / 100); + estimatedRemaining = Math.round(avgTimePerVideo * remainingVideos / 1000); + } else if (progress.eta) { + // Parse ETA from yt-dlp (format: MM:SS) + const [mins, secs] = progress.eta.split(':').map(Number); + const currentVideoRemaining = mins * 60 + secs; + const remainingVideos = totalVideos - videosCompleted - 1; + // Estimate based on current video + if (videoProgress > 10) { + const elapsed = (Date.now() - videoStartTime) / 1000; + const estimatedVideoTime = (elapsed / videoProgress) * 100; + estimatedRemaining = Math.round(currentVideoRemaining + (remainingVideos * estimatedVideoTime)); + } + } + + sendEvent('progress', { + percent: Math.round(overallPercent * 10) / 10, + videoPercent: Math.round(videoProgress * 10) / 10, + currentVideo: progress.videoIndex || 1, + totalVideos: progress.totalVideos || totalVideos, + title: progress.title, + speed: progress.speed, + eta: progress.eta, + estimatedRemaining, + phase: 'downloading', + }); + }, + onVideoComplete: (video) => { + const videoTime = Date.now() - videoStartTime; + videoTimes.push(videoTime); + videosCompleted++; + videoStartTime = Date.now(); + + sendEvent('video-complete', { + title: video.title, + success: video.success, + videosCompleted, + totalVideos, + }); + }, + }); + + // Send final result + sendEvent('complete', { + success: true, + playlistTitle: result.playlistTitle, + totalVideos: result.totalVideos, + successCount: result.successCount, + failCount: result.failCount, + totalTime: Math.round((Date.now() - startTime) / 1000), + videos: result.videos.map(v => ({ + success: v.success, + title: v.title, + filePath: v.filePath, + fileUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, + error: v.error, + })), + }); + + } catch (error) { + sendEvent('error', { message: error.message }); + } finally { + res.end(); + } +}); + +/** + * POST /download + * Download a video or playlist as MP3 (non-streaming version) + * Body: { url: string, outputPath?: string } + */ +app.post('/download', async (req, res) => { + try { + const { url, outputPath } = req.body; + const outputDir = outputPath || OUTPUT_DIR; + + if (!url) { + return res.status(400).json({ error: 'URL required in request body' }); + } + + console.log(`Downloading: ${url}`); + const result = await download(url, { outputDir }); + + res.json({ + success: true, + playlistTitle: result.playlistTitle, + totalVideos: result.totalVideos, + successCount: result.successCount, + failCount: result.failCount, + videos: result.videos.map(v => ({ + success: v.success, + title: v.title, + filePath: v.filePath, + fileUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, + error: v.error, + })), + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /transcribe + * Transcribe an existing audio file + * Body: { filePath: string, language?: string, format?: string, outputPath?: string } + */ +app.post('/transcribe', async (req, res) => { + try { + const { filePath, language, format = 'txt', model = 'gpt-4o-mini-transcribe', outputPath } = req.body; + + if (!filePath) { + return res.status(400).json({ error: 'filePath required in request body' }); + } + + if (!process.env.OPENAI_API_KEY) { + return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); + } + + console.log(`Transcribing: ${filePath} with model ${model}`); + const result = await transcribeAndSave(filePath, { + language, + responseFormat: format === 'txt' ? 'text' : format, + outputFormat: format, + model, + outputDir: outputPath, + }); + + res.json({ + success: true, + filePath: result.filePath, + transcriptionPath: result.transcriptionPath, + transcriptionUrl: `/files/${path.basename(result.transcriptionPath)}`, + text: result.text, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /upload-transcribe + * Upload audio files and transcribe them + * Body: { language?: string, model?: string, outputPath?: string } + */ +app.post('/upload-transcribe', upload.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 { language, model = 'gpt-4o-mini-transcribe', outputPath } = req.body; + const results = []; + + console.log(`Transcribing ${req.files.length} uploaded files with model ${model}`); + + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + console.log(`[${i + 1}/${req.files.length}] Transcribing: ${file.originalname}`); + + try { + const result = await transcribeAndSave(file.path, { + language: language || undefined, + responseFormat: 'text', + outputFormat: 'txt', + model, + outputDir: outputPath, + }); + + results.push({ + success: true, + fileName: file.originalname, + filePath: file.path, + transcriptionPath: result.transcriptionPath, + transcriptionUrl: `/files/${path.basename(result.transcriptionPath)}`, + text: result.text, + }); + } catch (error) { + console.error(`Failed to transcribe ${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 }); + } +}); + +/** + * POST /convert-to-mp3 + * Upload video/audio files and convert them to MP3 + */ +app.post('/convert-to-mp3', uploadVideo.array('files', 50), async (req, res) => { + try { + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const { bitrate = '192k', quality = '2' } = req.body; + const results = []; + + console.log(`Converting ${req.files.length} files to MP3`); + + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + console.log(`[${i + 1}/${req.files.length}] Converting: ${file.originalname}`); + + try { + const result = await convertToMP3(file.path, { + outputDir: OUTPUT_DIR, + bitrate, + quality, + }); + + results.push({ + success: true, + fileName: file.originalname, + inputPath: file.path, + outputPath: result.outputPath, + outputUrl: `/files/${path.basename(result.outputPath)}`, + size: result.sizeHuman, + }); + } catch (error) { + console.error(`Failed to convert ${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 /supported-formats + * Get supported video/audio formats for conversion + */ +app.get('/supported-formats', (req, res) => { + res.json({ formats: getSupportedFormats() }); +}); + +/** + * GET /process-stream + * Download and transcribe with SSE progress updates + * Query: url, language?, model?, outputPath? + */ +app.get('/process-stream', async (req, res) => { + const { url, language, model = 'gpt-4o-mini-transcribe', outputPath } = 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 videosDownloaded = 0; + let videosTranscribed = 0; + let totalVideos = 1; + const videoTimes = []; + + try { + // Phase 1: Get info + sendEvent('status', { message: 'Fetching video info...', phase: 'info' }); + 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 + console.log(`Processing: ${url}`); + let videoStartTime = Date.now(); + + const downloadResult = await download(url, { + outputDir: outputPath || OUTPUT_DIR, + onDownloadProgress: (progress) => { + const videoProgress = progress.percent || 0; + // Download is 50% of total, transcribe is other 50% + const overallPercent = ((videosDownloaded + (videoProgress / 100)) / totalVideos) * 50; + + sendEvent('progress', { + percent: Math.round(overallPercent * 10) / 10, + videoPercent: Math.round(videoProgress * 10) / 10, + currentVideo: progress.videoIndex || 1, + totalVideos: progress.totalVideos || totalVideos, + title: progress.title, + speed: progress.speed, + eta: progress.eta, + phase: 'downloading', + phaseLabel: 'Downloading', + }); + }, + onVideoComplete: (video) => { + const videoTime = Date.now() - videoStartTime; + videoTimes.push(videoTime); + videosDownloaded++; + videoStartTime = Date.now(); + + sendEvent('video-complete', { + title: video.title, + success: video.success, + phase: 'downloading', + videosCompleted: videosDownloaded, + totalVideos, + }); + }, + }); + + // Phase 3: Transcribe + sendEvent('status', { message: 'Starting transcription...', phase: 'transcribing' }); + + const successfulDownloads = downloadResult.videos.filter(v => v.success); + const filePaths = successfulDownloads.map(v => v.filePath); + const transcribeResults = []; + + for (let i = 0; i < filePaths.length; i++) { + const filePath = filePaths[i]; + const video = successfulDownloads[i]; + + sendEvent('progress', { + percent: 50 + ((i / filePaths.length) * 50), + currentVideo: i + 1, + totalVideos: filePaths.length, + title: video.title, + phase: 'transcribing', + phaseLabel: 'Transcribing', + }); + + try { + const result = await transcribeAndSave(filePath, { + language: language || undefined, + responseFormat: 'text', + outputFormat: 'txt', + model, + outputDir: outputPath, + }); + transcribeResults.push(result); + videosTranscribed++; + + sendEvent('transcribe-complete', { + title: video.title, + success: true, + videosCompleted: videosTranscribed, + totalFiles: filePaths.length, + }); + } catch (error) { + transcribeResults.push({ + success: false, + filePath, + error: error.message, + }); + + sendEvent('transcribe-complete', { + title: video.title, + success: false, + error: error.message, + videosCompleted: videosTranscribed, + totalFiles: filePaths.length, + }); + } + } + + // Combine results + const combinedResults = downloadResult.videos.map(v => { + const transcription = transcribeResults.find(t => t.filePath === v.filePath); + return { + title: v.title, + downloadSuccess: v.success, + audioUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, + transcriptionSuccess: transcription?.success || false, + transcriptionUrl: transcription?.transcriptionPath + ? `/files/${path.basename(transcription.transcriptionPath)}` + : null, + text: transcription?.text, + error: v.error || transcription?.error, + }; + }); + + sendEvent('complete', { + success: true, + playlistTitle: downloadResult.playlistTitle, + totalVideos: downloadResult.totalVideos, + downloadedCount: downloadResult.successCount, + transcribedCount: videosTranscribed, + totalTime: Math.round((Date.now() - startTime) / 1000), + results: combinedResults, + }); + + } catch (error) { + sendEvent('error', { message: error.message }); + } finally { + res.end(); + } +}); + +/** + * POST /process + * Download and transcribe a video or playlist (non-streaming) + * Body: { url: string, language?: string, format?: string, outputPath?: string } + */ +app.post('/process', async (req, res) => { + try { + const { url, language, format = 'txt', outputPath, model = 'gpt-4o-mini-transcribe' } = req.body; + const outputDir = outputPath || OUTPUT_DIR; + + if (!url) { + return res.status(400).json({ error: 'URL required in request body' }); + } + + if (!process.env.OPENAI_API_KEY) { + return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); + } + + // Step 1: Download + console.log(`Step 1: Downloading ${url}`); + const downloadResult = await download(url, { outputDir }); + + // Step 2: Transcribe + console.log(`Step 2: Transcribing with model ${model}...`); + const successfulDownloads = downloadResult.videos.filter(v => v.success); + const filePaths = successfulDownloads.map(v => v.filePath); + + const transcribeResult = await transcribeMultiple(filePaths, { + language, + responseFormat: format === 'txt' ? 'text' : format, + outputFormat: format, + model, + outputDir, + }); + + // Combine results + const combinedResults = downloadResult.videos.map(v => { + const transcription = transcribeResult.results.find( + t => t.filePath === v.filePath + ); + + return { + title: v.title, + downloadSuccess: v.success, + audioPath: v.filePath, + audioUrl: v.filePath ? `/files/${path.basename(v.filePath)}` : null, + transcriptionSuccess: transcription?.success || false, + transcriptionPath: transcription?.transcriptionPath, + transcriptionUrl: transcription?.transcriptionPath + ? `/files/${path.basename(transcription.transcriptionPath)}` + : null, + text: transcription?.text, + error: v.error || transcription?.error, + }; + }); + + res.json({ + success: true, + playlistTitle: downloadResult.playlistTitle, + totalVideos: downloadResult.totalVideos, + downloadedCount: downloadResult.successCount, + transcribedCount: transcribeResult.successCount, + results: combinedResults, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /files + * List all downloaded files + */ +app.get('/files-list', (req, res) => { + try { + if (!fs.existsSync(OUTPUT_DIR)) { + return res.json({ files: [] }); + } + + const files = fs.readdirSync(OUTPUT_DIR).map(file => ({ + name: file, + url: `/files/${file}`, + path: path.join(OUTPUT_DIR, file), + })); + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /languages + * Get available translation languages + */ +app.get('/languages', (req, res) => { + res.json({ languages: getLanguages() }); +}); + +/** + * POST /translate + * Translate text + * Body: { text: string, targetLang: string, sourceLang?: string } + */ +app.post('/translate', async (req, res) => { + try { + const { text, targetLang, sourceLang } = req.body; + + if (!text) { + return res.status(400).json({ error: 'text required in request body' }); + } + if (!targetLang) { + return res.status(400).json({ error: 'targetLang required in request body' }); + } + + if (!process.env.OPENAI_API_KEY) { + return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); + } + + console.log(`Translating text to ${targetLang}`); + const result = await translateText(text, targetLang, sourceLang); + + res.json({ + success: true, + ...result, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /translate-file + * Translate uploaded text files + * Body: { targetLang: string, sourceLang?: string, outputPath?: string } + */ +app.post('/translate-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 { targetLang, sourceLang, outputPath } = req.body; + + if (!targetLang) { + return res.status(400).json({ error: 'targetLang required' }); + } + + const results = []; + console.log(`Translating ${req.files.length} files to ${targetLang}`); + + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + console.log(`[${i + 1}/${req.files.length}] Translating: ${file.originalname}`); + + try { + const result = await translateFile(file.path, targetLang, sourceLang || null, outputPath); + results.push({ + success: true, + fileName: file.originalname, + translationPath: result.translationPath, + translationUrl: `/files/${path.basename(result.translationPath)}`, + translatedText: result.translatedText, + }); + } catch (error) { + console.error(`Failed to translate ${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 /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 + * Body: { style?: string, language?: string, model?: string, outputPath?: string } + */ +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', outputPath } = 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, outputDir: outputPath }); + 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?, outputPath? + */ +app.get('/summarize-stream', async (req, res) => { + const { url, style = 'concise', language = 'same', model = 'gpt-4o-mini-transcribe', outputPath } = 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: outputPath || 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, + outputDir: outputPath, + }); + 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', outputDir: outputPath }); + 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(' 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/ - Serve downloaded files'); +}); diff --git a/src/services/conversion.js b/src/services/conversion.js index 9b5b23b..2fe0ff7 100644 --- a/src/services/conversion.js +++ b/src/services/conversion.js @@ -1,145 +1,145 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; -import path from 'path'; -import fs from 'fs'; - -const execPromise = promisify(exec); - -/** - * Convert a video/audio file to MP3 using FFmpeg - * @param {string} inputPath - Path to input file - * @param {object} options - Conversion options - * @param {string} options.outputDir - Output directory (default: same as input) - * @param {string} options.bitrate - Audio bitrate (default: 192k) - * @param {string} options.quality - Audio quality 0-9 (default: 2, where 0 is best) - * @returns {Promise} Conversion result with output path - */ -export async function convertToMP3(inputPath, options = {}) { - const { - outputDir = path.dirname(inputPath), - bitrate = '192k', - quality = '2', - } = options; - - // Ensure input file exists - if (!fs.existsSync(inputPath)) { - throw new Error(`Input file not found: ${inputPath}`); - } - - // Generate output path - const inputFilename = path.basename(inputPath, path.extname(inputPath)); - const outputPath = path.join(outputDir, `${inputFilename}.mp3`); - - // Check if output already exists - if (fs.existsSync(outputPath)) { - // Add timestamp to make it unique - const timestamp = Date.now(); - const uniqueOutputPath = path.join(outputDir, `${inputFilename}_${timestamp}.mp3`); - return convertToMP3Internal(inputPath, uniqueOutputPath, bitrate, quality); - } - - return convertToMP3Internal(inputPath, outputPath, bitrate, quality); -} - -/** - * Internal conversion function - */ -async function convertToMP3Internal(inputPath, outputPath, bitrate, quality) { - try { - // FFmpeg command to convert to MP3 - // -i: input file - // -vn: no video (audio only) - // -ar 44100: audio sample rate 44.1kHz - // -ac 2: stereo - // -b:a: audio bitrate - // -q:a: audio quality (VBR) - const command = `ffmpeg -i "${inputPath}" -vn -ar 44100 -ac 2 -b:a ${bitrate} -q:a ${quality} "${outputPath}"`; - - console.log(`Converting: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`); - - const { stdout, stderr } = await execPromise(command); - - // Verify output file was created - if (!fs.existsSync(outputPath)) { - throw new Error('Conversion failed: output file not created'); - } - - const stats = fs.statSync(outputPath); - - return { - success: true, - inputPath, - outputPath, - filename: path.basename(outputPath), - size: stats.size, - sizeHuman: formatBytes(stats.size), - }; - } catch (error) { - console.error(`Conversion error: ${error.message}`); - throw new Error(`FFmpeg conversion failed: ${error.message}`); - } -} - -/** - * Convert multiple files to MP3 - * @param {string[]} inputPaths - Array of input file paths - * @param {object} options - Conversion options - * @returns {Promise} Batch conversion results - */ -export async function convertMultipleToMP3(inputPaths, options = {}) { - const results = []; - let successCount = 0; - let failCount = 0; - - for (let i = 0; i < inputPaths.length; i++) { - const inputPath = inputPaths[i]; - console.log(`[${i + 1}/${inputPaths.length}] Converting: ${path.basename(inputPath)}`); - - try { - const result = await convertToMP3(inputPath, options); - results.push({ ...result, index: i }); - successCount++; - } catch (error) { - results.push({ - success: false, - inputPath, - error: error.message, - index: i, - }); - failCount++; - console.error(`Failed to convert ${path.basename(inputPath)}: ${error.message}`); - } - } - - return { - totalFiles: inputPaths.length, - successCount, - failCount, - results, - }; -} - -/** - * Format bytes to human readable format - */ -function formatBytes(bytes, decimals = 2) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} - -/** - * Get supported input formats - */ -export function getSupportedFormats() { - return { - video: ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v'], - audio: ['.m4a', '.wav', '.flac', '.ogg', '.aac', '.wma', '.opus'], - }; -} +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import fs from 'fs'; + +const execPromise = promisify(exec); + +/** + * Convert a video/audio file to MP3 using FFmpeg + * @param {string} inputPath - Path to input file + * @param {object} options - Conversion options + * @param {string} options.outputDir - Output directory (default: same as input) + * @param {string} options.bitrate - Audio bitrate (default: 192k) + * @param {string} options.quality - Audio quality 0-9 (default: 2, where 0 is best) + * @returns {Promise} Conversion result with output path + */ +export async function convertToMP3(inputPath, options = {}) { + const { + outputDir = path.dirname(inputPath), + bitrate = '192k', + quality = '2', + } = options; + + // Ensure input file exists + if (!fs.existsSync(inputPath)) { + throw new Error(`Input file not found: ${inputPath}`); + } + + // Generate output path + const inputFilename = path.basename(inputPath, path.extname(inputPath)); + const outputPath = path.join(outputDir, `${inputFilename}.mp3`); + + // Check if output already exists + if (fs.existsSync(outputPath)) { + // Add timestamp to make it unique + const timestamp = Date.now(); + const uniqueOutputPath = path.join(outputDir, `${inputFilename}_${timestamp}.mp3`); + return convertToMP3Internal(inputPath, uniqueOutputPath, bitrate, quality); + } + + return convertToMP3Internal(inputPath, outputPath, bitrate, quality); +} + +/** + * Internal conversion function + */ +async function convertToMP3Internal(inputPath, outputPath, bitrate, quality) { + try { + // FFmpeg command to convert to MP3 + // -i: input file + // -vn: no video (audio only) + // -ar 44100: audio sample rate 44.1kHz + // -ac 2: stereo + // -b:a: audio bitrate + // -q:a: audio quality (VBR) + const command = `ffmpeg -i "${inputPath}" -vn -ar 44100 -ac 2 -b:a ${bitrate} -q:a ${quality} "${outputPath}"`; + + console.log(`Converting: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`); + + const { stdout, stderr } = await execPromise(command); + + // Verify output file was created + if (!fs.existsSync(outputPath)) { + throw new Error('Conversion failed: output file not created'); + } + + const stats = fs.statSync(outputPath); + + return { + success: true, + inputPath, + outputPath, + filename: path.basename(outputPath), + size: stats.size, + sizeHuman: formatBytes(stats.size), + }; + } catch (error) { + console.error(`Conversion error: ${error.message}`); + throw new Error(`FFmpeg conversion failed: ${error.message}`); + } +} + +/** + * Convert multiple files to MP3 + * @param {string[]} inputPaths - Array of input file paths + * @param {object} options - Conversion options + * @returns {Promise} Batch conversion results + */ +export async function convertMultipleToMP3(inputPaths, options = {}) { + const results = []; + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < inputPaths.length; i++) { + const inputPath = inputPaths[i]; + console.log(`[${i + 1}/${inputPaths.length}] Converting: ${path.basename(inputPath)}`); + + try { + const result = await convertToMP3(inputPath, options); + results.push({ ...result, index: i }); + successCount++; + } catch (error) { + results.push({ + success: false, + inputPath, + error: error.message, + index: i, + }); + failCount++; + console.error(`Failed to convert ${path.basename(inputPath)}: ${error.message}`); + } + } + + return { + totalFiles: inputPaths.length, + successCount, + failCount, + results, + }; +} + +/** + * Format bytes to human readable format + */ +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +/** + * Get supported input formats + */ +export function getSupportedFormats() { + return { + video: ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v'], + audio: ['.m4a', '.wav', '.flac', '.ogg', '.aac', '.wma', '.opus'], + }; +} diff --git a/src/services/summarize.js b/src/services/summarize.js index 4acb8bd..44ee794 100644 --- a/src/services/summarize.js +++ b/src/services/summarize.js @@ -1,195 +1,195 @@ -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 { outputDir, ...otherOptions } = options; - - const text = fs.readFileSync(filePath, 'utf-8'); - const result = await summarizeText(text, otherOptions); - - // Save summary to file - const dir = outputDir || 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', - }; -} +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 { outputDir, ...otherOptions } = options; + + const text = fs.readFileSync(filePath, 'utf-8'); + const result = await summarizeText(text, otherOptions); + + // Save summary to file + const dir = outputDir || 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', + }; +} diff --git a/src/services/transcription.js b/src/services/transcription.js index b3b5f28..61e65f4 100644 --- a/src/services/transcription.js +++ b/src/services/transcription.js @@ -1,178 +1,178 @@ -import OpenAI from 'openai'; -import fs from 'fs'; -import path from 'path'; - -let openai = null; - -// Available transcription models -const MODELS = { - 'gpt-4o-transcribe': { - name: 'gpt-4o-transcribe', - formats: ['json', 'text'], - supportsLanguage: true, - }, - 'gpt-4o-mini-transcribe': { - name: 'gpt-4o-mini-transcribe', - formats: ['json', 'text'], - supportsLanguage: true, - }, - 'whisper-1': { - name: 'whisper-1', - formats: ['json', 'text', 'srt', 'vtt', 'verbose_json'], - supportsLanguage: true, - }, -}; - -const DEFAULT_MODEL = 'gpt-4o-mini-transcribe'; - -/** - * 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; -} - -/** - * Get available models - */ -export function getAvailableModels() { - return Object.keys(MODELS); -} - -/** - * Transcribe an audio file using OpenAI API - * @param {string} filePath - Path to audio file - * @param {Object} options - Transcription options - * @param {string} options.language - Language code (e.g., 'en', 'fr', 'es', 'zh') - * @param {string} options.responseFormat - Output format: 'json' or 'text' (gpt-4o models), or 'srt'/'vtt' (whisper-1 only) - * @param {string} options.prompt - Optional context prompt for better accuracy - * @param {string} options.model - Model to use (default: gpt-4o-transcribe) - */ -export async function transcribeFile(filePath, options = {}) { - const { - language = null, // Auto-detect if null - responseFormat = 'text', // json or text for gpt-4o models - prompt = null, // Optional context prompt - model = DEFAULT_MODEL, - } = options; - - if (!fs.existsSync(filePath)) { - throw new Error(`File not found: ${filePath}`); - } - - const modelConfig = MODELS[model] || MODELS[DEFAULT_MODEL]; - const actualModel = modelConfig.name; - - // Validate response format for model - let actualFormat = responseFormat; - if (!modelConfig.formats.includes(responseFormat)) { - console.warn(`Format '${responseFormat}' not supported by ${actualModel}, using 'text'`); - actualFormat = 'text'; - } - - try { - const transcriptionOptions = { - file: fs.createReadStream(filePath), - model: actualModel, - response_format: actualFormat, - }; - - if (language) { - transcriptionOptions.language = language; - } - - if (prompt) { - transcriptionOptions.prompt = prompt; - } - - console.log(`Using model: ${actualModel}, format: ${actualFormat}${language ? `, language: ${language}` : ''}`); - - const transcription = await getOpenAI().audio.transcriptions.create(transcriptionOptions); - - return { - success: true, - filePath, - text: actualFormat === 'json' || actualFormat === 'verbose_json' - ? transcription.text - : transcription, - format: actualFormat, - model: actualModel, - }; - } catch (error) { - throw new Error(`Transcription failed: ${error.message}`); - } -} - -/** - * Transcribe and save to file - */ -export async function transcribeAndSave(filePath, options = {}) { - const { outputFormat = 'txt', outputDir = null } = options; - - const result = await transcribeFile(filePath, options); - - // Determine output path - const baseName = path.basename(filePath, path.extname(filePath)); - const outputPath = path.join( - outputDir || path.dirname(filePath), - `${baseName}.${outputFormat}` - ); - - // Save transcription - fs.writeFileSync(outputPath, result.text, 'utf-8'); - - return { - ...result, - transcriptionPath: outputPath, - }; -} - -/** - * Transcribe multiple files - */ -export async function transcribeMultiple(filePaths, options = {}) { - const { onProgress, onFileComplete } = options; - const results = []; - - for (let i = 0; i < filePaths.length; i++) { - const filePath = filePaths[i]; - - if (onProgress) { - onProgress({ current: i + 1, total: filePaths.length, filePath }); - } - - console.log(`[${i + 1}/${filePaths.length}] Transcribing: ${path.basename(filePath)}`); - - try { - const result = await transcribeAndSave(filePath, options); - results.push(result); - - if (onFileComplete) { - onFileComplete(result); - } - } catch (error) { - console.error(`Failed to transcribe ${filePath}: ${error.message}`); - results.push({ - success: false, - filePath, - error: error.message, - }); - } - } - - return { - success: true, - results, - totalFiles: filePaths.length, - successCount: results.filter(r => r.success).length, - failCount: results.filter(r => !r.success).length, - }; -} +import OpenAI from 'openai'; +import fs from 'fs'; +import path from 'path'; + +let openai = null; + +// Available transcription models +const MODELS = { + 'gpt-4o-transcribe': { + name: 'gpt-4o-transcribe', + formats: ['json', 'text'], + supportsLanguage: true, + }, + 'gpt-4o-mini-transcribe': { + name: 'gpt-4o-mini-transcribe', + formats: ['json', 'text'], + supportsLanguage: true, + }, + 'whisper-1': { + name: 'whisper-1', + formats: ['json', 'text', 'srt', 'vtt', 'verbose_json'], + supportsLanguage: true, + }, +}; + +const DEFAULT_MODEL = 'gpt-4o-mini-transcribe'; + +/** + * 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; +} + +/** + * Get available models + */ +export function getAvailableModels() { + return Object.keys(MODELS); +} + +/** + * Transcribe an audio file using OpenAI API + * @param {string} filePath - Path to audio file + * @param {Object} options - Transcription options + * @param {string} options.language - Language code (e.g., 'en', 'fr', 'es', 'zh') + * @param {string} options.responseFormat - Output format: 'json' or 'text' (gpt-4o models), or 'srt'/'vtt' (whisper-1 only) + * @param {string} options.prompt - Optional context prompt for better accuracy + * @param {string} options.model - Model to use (default: gpt-4o-transcribe) + */ +export async function transcribeFile(filePath, options = {}) { + const { + language = null, // Auto-detect if null + responseFormat = 'text', // json or text for gpt-4o models + prompt = null, // Optional context prompt + model = DEFAULT_MODEL, + } = options; + + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const modelConfig = MODELS[model] || MODELS[DEFAULT_MODEL]; + const actualModel = modelConfig.name; + + // Validate response format for model + let actualFormat = responseFormat; + if (!modelConfig.formats.includes(responseFormat)) { + console.warn(`Format '${responseFormat}' not supported by ${actualModel}, using 'text'`); + actualFormat = 'text'; + } + + try { + const transcriptionOptions = { + file: fs.createReadStream(filePath), + model: actualModel, + response_format: actualFormat, + }; + + if (language) { + transcriptionOptions.language = language; + } + + if (prompt) { + transcriptionOptions.prompt = prompt; + } + + console.log(`Using model: ${actualModel}, format: ${actualFormat}${language ? `, language: ${language}` : ''}`); + + const transcription = await getOpenAI().audio.transcriptions.create(transcriptionOptions); + + return { + success: true, + filePath, + text: actualFormat === 'json' || actualFormat === 'verbose_json' + ? transcription.text + : transcription, + format: actualFormat, + model: actualModel, + }; + } catch (error) { + throw new Error(`Transcription failed: ${error.message}`); + } +} + +/** + * Transcribe and save to file + */ +export async function transcribeAndSave(filePath, options = {}) { + const { outputFormat = 'txt', outputDir = null } = options; + + const result = await transcribeFile(filePath, options); + + // Determine output path + const baseName = path.basename(filePath, path.extname(filePath)); + const outputPath = path.join( + outputDir || path.dirname(filePath), + `${baseName}.${outputFormat}` + ); + + // Save transcription + fs.writeFileSync(outputPath, result.text, 'utf-8'); + + return { + ...result, + transcriptionPath: outputPath, + }; +} + +/** + * Transcribe multiple files + */ +export async function transcribeMultiple(filePaths, options = {}) { + const { onProgress, onFileComplete } = options; + const results = []; + + for (let i = 0; i < filePaths.length; i++) { + const filePath = filePaths[i]; + + if (onProgress) { + onProgress({ current: i + 1, total: filePaths.length, filePath }); + } + + console.log(`[${i + 1}/${filePaths.length}] Transcribing: ${path.basename(filePath)}`); + + try { + const result = await transcribeAndSave(filePath, options); + results.push(result); + + if (onFileComplete) { + onFileComplete(result); + } + } catch (error) { + console.error(`Failed to transcribe ${filePath}: ${error.message}`); + results.push({ + success: false, + filePath, + error: error.message, + }); + } + } + + return { + success: true, + results, + totalFiles: filePaths.length, + successCount: results.filter(r => r.success).length, + failCount: results.filter(r => !r.success).length, + }; +} diff --git a/src/services/translation.js b/src/services/translation.js index efbe1c0..e8fa424 100644 --- a/src/services/translation.js +++ b/src/services/translation.js @@ -1,271 +1,271 @@ -import OpenAI from 'openai'; -import fs from 'fs'; -import path from 'path'; - -let openai = null; - -// Max characters per chunk (~6000 tokens ≈ 24000 characters for most languages) -const MAX_CHUNK_CHARS = 20000; - -const LANGUAGES = { - en: 'English', - fr: 'French', - es: 'Spanish', - de: 'German', - it: 'Italian', - pt: 'Portuguese', - zh: 'Chinese', - ja: 'Japanese', - ko: 'Korean', - ru: 'Russian', - ar: 'Arabic', - hi: 'Hindi', - nl: 'Dutch', - pl: 'Polish', - tr: 'Turkish', - vi: 'Vietnamese', - th: 'Thai', - sv: 'Swedish', - da: 'Danish', - fi: 'Finnish', - no: 'Norwegian', - cs: 'Czech', - el: 'Greek', - he: 'Hebrew', - id: 'Indonesian', - ms: 'Malay', - ro: 'Romanian', - uk: 'Ukrainian', -}; - -// Sentence ending patterns for different languages -const SENTENCE_ENDINGS = /[.!?。!?。\n]/g; - -/** - * 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; -} - -/** - * Split text into chunks at sentence boundaries - * @param {string} text - Text to split - * @param {number} maxChars - Maximum characters per chunk - * @returns {string[]} Array of text chunks - */ -function splitIntoChunks(text, maxChars = MAX_CHUNK_CHARS) { - if (text.length <= maxChars) { - return [text]; - } - - const chunks = []; - let currentPos = 0; - - while (currentPos < text.length) { - let endPos = currentPos + maxChars; - - // If we're at the end, just take the rest - if (endPos >= text.length) { - chunks.push(text.slice(currentPos)); - break; - } - - // Find the last sentence ending before maxChars - const searchText = text.slice(currentPos, endPos); - let lastSentenceEnd = -1; - - // Find all sentence endings in the search range - let match; - SENTENCE_ENDINGS.lastIndex = 0; - while ((match = SENTENCE_ENDINGS.exec(searchText)) !== null) { - lastSentenceEnd = match.index + 1; // Include the punctuation - } - - // If we found a sentence ending, cut there - // Otherwise, look for the next sentence ending after maxChars (up to 20% more) - if (lastSentenceEnd > maxChars * 0.5) { - endPos = currentPos + lastSentenceEnd; - } else { - // Look forward for a sentence ending (up to 20% more characters) - const extendedSearch = text.slice(endPos, endPos + maxChars * 0.2); - SENTENCE_ENDINGS.lastIndex = 0; - const forwardMatch = SENTENCE_ENDINGS.exec(extendedSearch); - if (forwardMatch) { - endPos = endPos + forwardMatch.index + 1; - } - // If still no sentence ending found, just cut at maxChars - } - - chunks.push(text.slice(currentPos, endPos).trim()); - currentPos = endPos; - - // Skip any leading whitespace for the next chunk - while (currentPos < text.length && /\s/.test(text[currentPos])) { - currentPos++; - } - } - - return chunks.filter(chunk => chunk.length > 0); -} - -/** - * Get available languages - */ -export function getLanguages() { - return LANGUAGES; -} - -/** - * Translate a single chunk of text - */ -async function translateChunk(text, targetLanguage, sourceLanguage) { - const prompt = sourceLanguage - ? `Translate the following text from ${sourceLanguage} to ${targetLanguage}. Only output the translation, nothing else:\n\n${text}` - : `Translate the following text to ${targetLanguage}. Only output the translation, nothing else:\n\n${text}`; - - const response = await getOpenAI().chat.completions.create({ - model: 'gpt-4o-mini', - max_tokens: 16384, - messages: [ - { - role: 'user', - content: prompt, - }, - ], - }); - - return response.choices[0].message.content; -} - -/** - * Translate text using GPT-4o-mini with chunking for long texts - * @param {string} text - Text to translate - * @param {string} targetLang - Target language code (e.g., 'en', 'fr') - * @param {string} sourceLang - Source language code (optional, auto-detect if null) - */ -export async function translateText(text, targetLang, sourceLang = null) { - if (!text || !text.trim()) { - throw new Error('No text provided for translation'); - } - - const targetLanguage = LANGUAGES[targetLang] || targetLang; - const sourceLanguage = sourceLang ? (LANGUAGES[sourceLang] || sourceLang) : null; - - try { - // Split text into chunks - const chunks = splitIntoChunks(text); - - if (chunks.length === 1) { - // Single chunk - translate directly - const translation = await translateChunk(text, targetLanguage, sourceLanguage); - return { - success: true, - originalText: text, - translatedText: translation, - targetLanguage: targetLanguage, - sourceLanguage: sourceLanguage || 'auto-detected', - chunks: 1, - }; - } - - // Multiple chunks - translate each and combine - console.log(`Splitting text into ${chunks.length} chunks for translation...`); - const translations = []; - - for (let i = 0; i < chunks.length; i++) { - console.log(` Translating chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`); - const translation = await translateChunk(chunks[i], targetLanguage, sourceLanguage); - translations.push(translation); - } - - const combinedTranslation = translations.join('\n\n'); - - return { - success: true, - originalText: text, - translatedText: combinedTranslation, - targetLanguage: targetLanguage, - sourceLanguage: sourceLanguage || 'auto-detected', - chunks: chunks.length, - }; - } catch (error) { - throw new Error(`Translation failed: ${error.message}`); - } -} - -/** - * Translate a text file - * @param {string} filePath - Path to text file - * @param {string} targetLang - Target language code - * @param {string} sourceLang - Source language code (optional) - * @param {string} outputDir - Output directory (optional) - */ -export async function translateFile(filePath, targetLang, sourceLang = null, outputDir = null) { - if (!fs.existsSync(filePath)) { - throw new Error(`File not found: ${filePath}`); - } - - const text = fs.readFileSync(filePath, 'utf-8'); - const result = await translateText(text, targetLang, sourceLang); - - // Save translation - const baseName = path.basename(filePath, path.extname(filePath)); - const outputPath = path.join( - outputDir || path.dirname(filePath), - `${baseName}_${targetLang}.txt` - ); - - fs.writeFileSync(outputPath, result.translatedText, 'utf-8'); - - return { - ...result, - originalPath: filePath, - translationPath: outputPath, - }; -} - -/** - * Translate multiple files - */ -export async function translateMultiple(filePaths, targetLang, sourceLang = null, outputDir = null, onProgress = null) { - const results = []; - - for (let i = 0; i < filePaths.length; i++) { - const filePath = filePaths[i]; - - if (onProgress) { - onProgress({ current: i + 1, total: filePaths.length, filePath }); - } - - console.log(`[${i + 1}/${filePaths.length}] Translating: ${path.basename(filePath)}`); - - try { - const result = await translateFile(filePath, targetLang, sourceLang, outputDir); - results.push(result); - } catch (error) { - console.error(`Failed to translate ${filePath}: ${error.message}`); - results.push({ - success: false, - originalPath: filePath, - error: error.message, - }); - } - } - - return { - success: true, - results, - totalFiles: filePaths.length, - successCount: results.filter(r => r.success).length, - failCount: results.filter(r => !r.success).length, - }; -} +import OpenAI from 'openai'; +import fs from 'fs'; +import path from 'path'; + +let openai = null; + +// Max characters per chunk (~6000 tokens ≈ 24000 characters for most languages) +const MAX_CHUNK_CHARS = 20000; + +const LANGUAGES = { + en: 'English', + fr: 'French', + es: 'Spanish', + de: 'German', + it: 'Italian', + pt: 'Portuguese', + zh: 'Chinese', + ja: 'Japanese', + ko: 'Korean', + ru: 'Russian', + ar: 'Arabic', + hi: 'Hindi', + nl: 'Dutch', + pl: 'Polish', + tr: 'Turkish', + vi: 'Vietnamese', + th: 'Thai', + sv: 'Swedish', + da: 'Danish', + fi: 'Finnish', + no: 'Norwegian', + cs: 'Czech', + el: 'Greek', + he: 'Hebrew', + id: 'Indonesian', + ms: 'Malay', + ro: 'Romanian', + uk: 'Ukrainian', +}; + +// Sentence ending patterns for different languages +const SENTENCE_ENDINGS = /[.!?。!?。\n]/g; + +/** + * 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; +} + +/** + * Split text into chunks at sentence boundaries + * @param {string} text - Text to split + * @param {number} maxChars - Maximum characters per chunk + * @returns {string[]} Array of text chunks + */ +function splitIntoChunks(text, maxChars = MAX_CHUNK_CHARS) { + if (text.length <= maxChars) { + return [text]; + } + + const chunks = []; + let currentPos = 0; + + while (currentPos < text.length) { + let endPos = currentPos + maxChars; + + // If we're at the end, just take the rest + if (endPos >= text.length) { + chunks.push(text.slice(currentPos)); + break; + } + + // Find the last sentence ending before maxChars + const searchText = text.slice(currentPos, endPos); + let lastSentenceEnd = -1; + + // Find all sentence endings in the search range + let match; + SENTENCE_ENDINGS.lastIndex = 0; + while ((match = SENTENCE_ENDINGS.exec(searchText)) !== null) { + lastSentenceEnd = match.index + 1; // Include the punctuation + } + + // If we found a sentence ending, cut there + // Otherwise, look for the next sentence ending after maxChars (up to 20% more) + if (lastSentenceEnd > maxChars * 0.5) { + endPos = currentPos + lastSentenceEnd; + } else { + // Look forward for a sentence ending (up to 20% more characters) + const extendedSearch = text.slice(endPos, endPos + maxChars * 0.2); + SENTENCE_ENDINGS.lastIndex = 0; + const forwardMatch = SENTENCE_ENDINGS.exec(extendedSearch); + if (forwardMatch) { + endPos = endPos + forwardMatch.index + 1; + } + // If still no sentence ending found, just cut at maxChars + } + + chunks.push(text.slice(currentPos, endPos).trim()); + currentPos = endPos; + + // Skip any leading whitespace for the next chunk + while (currentPos < text.length && /\s/.test(text[currentPos])) { + currentPos++; + } + } + + return chunks.filter(chunk => chunk.length > 0); +} + +/** + * Get available languages + */ +export function getLanguages() { + return LANGUAGES; +} + +/** + * Translate a single chunk of text + */ +async function translateChunk(text, targetLanguage, sourceLanguage) { + const prompt = sourceLanguage + ? `Translate the following text from ${sourceLanguage} to ${targetLanguage}. Only output the translation, nothing else:\n\n${text}` + : `Translate the following text to ${targetLanguage}. Only output the translation, nothing else:\n\n${text}`; + + const response = await getOpenAI().chat.completions.create({ + model: 'gpt-4o-mini', + max_tokens: 16384, + messages: [ + { + role: 'user', + content: prompt, + }, + ], + }); + + return response.choices[0].message.content; +} + +/** + * Translate text using GPT-4o-mini with chunking for long texts + * @param {string} text - Text to translate + * @param {string} targetLang - Target language code (e.g., 'en', 'fr') + * @param {string} sourceLang - Source language code (optional, auto-detect if null) + */ +export async function translateText(text, targetLang, sourceLang = null) { + if (!text || !text.trim()) { + throw new Error('No text provided for translation'); + } + + const targetLanguage = LANGUAGES[targetLang] || targetLang; + const sourceLanguage = sourceLang ? (LANGUAGES[sourceLang] || sourceLang) : null; + + try { + // Split text into chunks + const chunks = splitIntoChunks(text); + + if (chunks.length === 1) { + // Single chunk - translate directly + const translation = await translateChunk(text, targetLanguage, sourceLanguage); + return { + success: true, + originalText: text, + translatedText: translation, + targetLanguage: targetLanguage, + sourceLanguage: sourceLanguage || 'auto-detected', + chunks: 1, + }; + } + + // Multiple chunks - translate each and combine + console.log(`Splitting text into ${chunks.length} chunks for translation...`); + const translations = []; + + for (let i = 0; i < chunks.length; i++) { + console.log(` Translating chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`); + const translation = await translateChunk(chunks[i], targetLanguage, sourceLanguage); + translations.push(translation); + } + + const combinedTranslation = translations.join('\n\n'); + + return { + success: true, + originalText: text, + translatedText: combinedTranslation, + targetLanguage: targetLanguage, + sourceLanguage: sourceLanguage || 'auto-detected', + chunks: chunks.length, + }; + } catch (error) { + throw new Error(`Translation failed: ${error.message}`); + } +} + +/** + * Translate a text file + * @param {string} filePath - Path to text file + * @param {string} targetLang - Target language code + * @param {string} sourceLang - Source language code (optional) + * @param {string} outputDir - Output directory (optional) + */ +export async function translateFile(filePath, targetLang, sourceLang = null, outputDir = null) { + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const text = fs.readFileSync(filePath, 'utf-8'); + const result = await translateText(text, targetLang, sourceLang); + + // Save translation + const baseName = path.basename(filePath, path.extname(filePath)); + const outputPath = path.join( + outputDir || path.dirname(filePath), + `${baseName}_${targetLang}.txt` + ); + + fs.writeFileSync(outputPath, result.translatedText, 'utf-8'); + + return { + ...result, + originalPath: filePath, + translationPath: outputPath, + }; +} + +/** + * Translate multiple files + */ +export async function translateMultiple(filePaths, targetLang, sourceLang = null, outputDir = null, onProgress = null) { + const results = []; + + for (let i = 0; i < filePaths.length; i++) { + const filePath = filePaths[i]; + + if (onProgress) { + onProgress({ current: i + 1, total: filePaths.length, filePath }); + } + + console.log(`[${i + 1}/${filePaths.length}] Translating: ${path.basename(filePath)}`); + + try { + const result = await translateFile(filePath, targetLang, sourceLang, outputDir); + results.push(result); + } catch (error) { + console.error(`Failed to translate ${filePath}: ${error.message}`); + results.push({ + success: false, + originalPath: filePath, + error: error.message, + }); + } + } + + return { + success: true, + results, + totalFiles: filePaths.length, + successCount: results.filter(r => r.success).length, + failCount: results.filter(r => !r.success).length, + }; +} diff --git a/src/services/youtube.js b/src/services/youtube.js index 55d53a5..5948d9a 100644 --- a/src/services/youtube.js +++ b/src/services/youtube.js @@ -1,291 +1,291 @@ -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'; - -/** - * Sanitize filename to remove invalid characters - */ -function sanitizeFilename(filename) { - return filename - .replace(/[<>:"/\\|?*]/g, '') - .replace(/\s+/g, '_') - .substring(0, 200); -} - -/** - * Check if URL contains a playlist parameter - */ -function hasPlaylistParam(url) { - try { - const urlObj = new URL(url); - return urlObj.searchParams.has('list'); - } catch { - return false; - } -} - -/** - * Extract playlist URL if present in the URL - */ -function extractPlaylistUrl(url) { - const urlObj = new URL(url); - const listId = urlObj.searchParams.get('list'); - if (listId) { - return `https://www.youtube.com/playlist?list=${listId}`; - } - return null; -} - -/** - * Get video/playlist info without downloading - */ -export async function getInfo(url, forcePlaylist = false) { - try { - // If URL contains a playlist ID and we want to force playlist mode - const playlistUrl = extractPlaylistUrl(url); - const targetUrl = (forcePlaylist && playlistUrl) ? playlistUrl : url; - - 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}`); - } -} - -/** - * Check if URL is a playlist - */ -export async function isPlaylist(url) { - const info = await getInfo(url); - return info._type === 'playlist'; -} - -/** - * Download a single video as MP3 - */ -export async function downloadVideo(url, options = {}) { - const { outputDir = OUTPUT_DIR, onProgress, onDownloadProgress } = options; - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - try { - // Get video info first - 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 - await ytdlpExec(url, [ - '--extract-audio', - '--audio-format', 'mp3', - '--audio-quality', '0', - '-o', outputPath, - '--no-warnings', - '--newline', - ], (progress) => { - if (onDownloadProgress) { - onDownloadProgress({ - ...progress, - title: info.title, - }); - } - }); - - return { - success: true, - title: info.title, - duration: info.duration, - filePath: outputPath, - url: url, - }; - } catch (error) { - throw new Error(`Failed to download: ${error.message}`); - } -} - -/** - * Download all videos from a playlist as MP3 - */ -export async function downloadPlaylist(url, options = {}) { - const { outputDir = OUTPUT_DIR, onProgress, onVideoComplete, onDownloadProgress, forcePlaylist = false } = options; - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - try { - // Get playlist info (force playlist mode if URL has list= param) - const info = await getInfo(url, forcePlaylist || hasPlaylistParam(url)); - - if (info._type !== 'playlist') { - // Single video, redirect to downloadVideo - const result = await downloadVideo(url, { ...options, onDownloadProgress }); - return { - success: true, - playlistTitle: result.title, - videos: [result], - totalVideos: 1, - }; - } - - const results = []; - const entries = info.entries || []; - - console.log(`Playlist: ${info.title} (${entries.length} videos)`); - - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - const videoUrl = entry.url || `https://www.youtube.com/watch?v=${entry.id}`; - - try { - if (onProgress) { - onProgress({ current: i + 1, total: entries.length, title: entry.title }); - } - - console.log(`[${i + 1}/${entries.length}] Downloading: ${entry.title}`); - - // Wrap progress callback to include playlist context - const wrappedProgress = onDownloadProgress ? (progress) => { - onDownloadProgress({ - ...progress, - videoIndex: i + 1, - totalVideos: entries.length, - playlistTitle: info.title, - }); - } : undefined; - - const result = await downloadVideo(videoUrl, { outputDir, onDownloadProgress: wrappedProgress }); - results.push(result); - - if (onVideoComplete) { - onVideoComplete(result); - } - } catch (error) { - console.error(`Failed to download ${entry.title}: ${error.message}`); - results.push({ - success: false, - title: entry.title, - url: videoUrl, - error: error.message, - }); - } - } - - return { - success: true, - playlistTitle: info.title, - videos: results, - totalVideos: entries.length, - successCount: results.filter(r => r.success).length, - failCount: results.filter(r => !r.success).length, - }; - } catch (error) { - throw new Error(`Failed to download playlist: ${error.message}`); - } -} - -/** - * Smart download - detects if URL is video or playlist - */ -export async function download(url, options = {}) { - // If URL contains list= parameter, treat it as a playlist - const isPlaylistUrl = hasPlaylistParam(url); - const info = await getInfo(url, isPlaylistUrl); - - if (info._type === 'playlist') { - return downloadPlaylist(url, { ...options, forcePlaylist: true }); - } else { - const result = await downloadVideo(url, options); - return { - success: true, - playlistTitle: null, - videos: [result], - totalVideos: 1, - successCount: 1, - failCount: 0, - }; - } -} +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'; + +/** + * Sanitize filename to remove invalid characters + */ +function sanitizeFilename(filename) { + return filename + .replace(/[<>:"/\\|?*]/g, '') + .replace(/\s+/g, '_') + .substring(0, 200); +} + +/** + * Check if URL contains a playlist parameter + */ +function hasPlaylistParam(url) { + try { + const urlObj = new URL(url); + return urlObj.searchParams.has('list'); + } catch { + return false; + } +} + +/** + * Extract playlist URL if present in the URL + */ +function extractPlaylistUrl(url) { + const urlObj = new URL(url); + const listId = urlObj.searchParams.get('list'); + if (listId) { + return `https://www.youtube.com/playlist?list=${listId}`; + } + return null; +} + +/** + * Get video/playlist info without downloading + */ +export async function getInfo(url, forcePlaylist = false) { + try { + // If URL contains a playlist ID and we want to force playlist mode + const playlistUrl = extractPlaylistUrl(url); + const targetUrl = (forcePlaylist && playlistUrl) ? playlistUrl : url; + + 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}`); + } +} + +/** + * Check if URL is a playlist + */ +export async function isPlaylist(url) { + const info = await getInfo(url); + return info._type === 'playlist'; +} + +/** + * Download a single video as MP3 + */ +export async function downloadVideo(url, options = {}) { + const { outputDir = OUTPUT_DIR, onProgress, onDownloadProgress } = options; + + // Ensure output directory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + try { + // Get video info first + 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 + await ytdlpExec(url, [ + '--extract-audio', + '--audio-format', 'mp3', + '--audio-quality', '0', + '-o', outputPath, + '--no-warnings', + '--newline', + ], (progress) => { + if (onDownloadProgress) { + onDownloadProgress({ + ...progress, + title: info.title, + }); + } + }); + + return { + success: true, + title: info.title, + duration: info.duration, + filePath: outputPath, + url: url, + }; + } catch (error) { + throw new Error(`Failed to download: ${error.message}`); + } +} + +/** + * Download all videos from a playlist as MP3 + */ +export async function downloadPlaylist(url, options = {}) { + const { outputDir = OUTPUT_DIR, onProgress, onVideoComplete, onDownloadProgress, forcePlaylist = false } = options; + + // Ensure output directory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + try { + // Get playlist info (force playlist mode if URL has list= param) + const info = await getInfo(url, forcePlaylist || hasPlaylistParam(url)); + + if (info._type !== 'playlist') { + // Single video, redirect to downloadVideo + const result = await downloadVideo(url, { ...options, onDownloadProgress }); + return { + success: true, + playlistTitle: result.title, + videos: [result], + totalVideos: 1, + }; + } + + const results = []; + const entries = info.entries || []; + + console.log(`Playlist: ${info.title} (${entries.length} videos)`); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const videoUrl = entry.url || `https://www.youtube.com/watch?v=${entry.id}`; + + try { + if (onProgress) { + onProgress({ current: i + 1, total: entries.length, title: entry.title }); + } + + console.log(`[${i + 1}/${entries.length}] Downloading: ${entry.title}`); + + // Wrap progress callback to include playlist context + const wrappedProgress = onDownloadProgress ? (progress) => { + onDownloadProgress({ + ...progress, + videoIndex: i + 1, + totalVideos: entries.length, + playlistTitle: info.title, + }); + } : undefined; + + const result = await downloadVideo(videoUrl, { outputDir, onDownloadProgress: wrappedProgress }); + results.push(result); + + if (onVideoComplete) { + onVideoComplete(result); + } + } catch (error) { + console.error(`Failed to download ${entry.title}: ${error.message}`); + results.push({ + success: false, + title: entry.title, + url: videoUrl, + error: error.message, + }); + } + } + + return { + success: true, + playlistTitle: info.title, + videos: results, + totalVideos: entries.length, + successCount: results.filter(r => r.success).length, + failCount: results.filter(r => !r.success).length, + }; + } catch (error) { + throw new Error(`Failed to download playlist: ${error.message}`); + } +} + +/** + * Smart download - detects if URL is video or playlist + */ +export async function download(url, options = {}) { + // If URL contains list= parameter, treat it as a playlist + const isPlaylistUrl = hasPlaylistParam(url); + const info = await getInfo(url, isPlaylistUrl); + + if (info._type === 'playlist') { + return downloadPlaylist(url, { ...options, forcePlaylist: true }); + } else { + const result = await downloadVideo(url, options); + return { + success: true, + playlistTitle: null, + videos: [result], + totalVideos: 1, + successCount: 1, + failCount: 0, + }; + } +} diff --git a/start-server.bat b/start-server.bat index 4a94129..e087a22 100644 --- a/start-server.bat +++ b/start-server.bat @@ -1,61 +1,61 @@ -@echo off -REM Video to MP3 Transcriptor Server Starter -REM This script starts the API server on port 8888 - -echo ========================================== -echo Video to MP3 Transcriptor API -echo ========================================== -echo. - -REM Check if node is installed -where node >nul 2>nul -if %ERRORLEVEL% NEQ 0 ( - echo Error: Node.js is not installed - echo Please install Node.js from https://nodejs.org/ - pause - exit /b 1 -) - -REM Check if npm is installed -where npm >nul 2>nul -if %ERRORLEVEL% NEQ 0 ( - echo Error: npm is not installed - echo Please install npm - pause - exit /b 1 -) - -REM Check if .env file exists -if not exist .env ( - echo Warning: .env file not found - echo Creating .env file... - ( - echo OPENAI_API_KEY= - echo PORT=8888 - echo OUTPUT_DIR=./output - ) > .env - echo. - echo Please edit .env and add your OPENAI_API_KEY - echo. -) - -REM Check if node_modules exists -if not exist node_modules ( - echo Installing dependencies... - call npm install - echo. -) - -REM Kill any process using port 8888 -echo Checking port 8888... -npx kill-port 8888 >nul 2>nul - -echo. -echo Starting server on http://localhost:8888 -echo Press Ctrl+C to stop the server -echo. -echo ========================================== -echo. - -REM Start the server -call npm run server +@echo off +REM Video to MP3 Transcriptor Server Starter +REM This script starts the API server on port 8888 + +echo ========================================== +echo Video to MP3 Transcriptor API +echo ========================================== +echo. + +REM Check if node is installed +where node >nul 2>nul +if %ERRORLEVEL% NEQ 0 ( + echo Error: Node.js is not installed + echo Please install Node.js from https://nodejs.org/ + pause + exit /b 1 +) + +REM Check if npm is installed +where npm >nul 2>nul +if %ERRORLEVEL% NEQ 0 ( + echo Error: npm is not installed + echo Please install npm + pause + exit /b 1 +) + +REM Check if .env file exists +if not exist .env ( + echo Warning: .env file not found + echo Creating .env file... + ( + echo OPENAI_API_KEY= + echo PORT=8888 + echo OUTPUT_DIR=./output + ) > .env + echo. + echo Please edit .env and add your OPENAI_API_KEY + echo. +) + +REM Check if node_modules exists +if not exist node_modules ( + echo Installing dependencies... + call npm install + echo. +) + +REM Kill any process using port 8888 +echo Checking port 8888... +npx kill-port 8888 >nul 2>nul + +echo. +echo Starting server on http://localhost:8888 +echo Press Ctrl+C to stop the server +echo. +echo ========================================== +echo. + +REM Start the server +call npm run server diff --git a/start-server.sh b/start-server.sh index 523385a..1bf5605 100644 --- a/start-server.sh +++ b/start-server.sh @@ -1,58 +1,58 @@ -#!/bin/bash - -# Video to MP3 Transcriptor Server Starter -# This script starts the API server on port 8888 - -echo "==========================================" -echo "Video to MP3 Transcriptor API" -echo "==========================================" -echo "" - -# Check if node is installed -if ! command -v node &> /dev/null -then - echo "Error: Node.js is not installed" - echo "Please install Node.js from https://nodejs.org/" - exit 1 -fi - -# Check if npm is installed -if ! command -v npm &> /dev/null -then - echo "Error: npm is not installed" - echo "Please install npm" - exit 1 -fi - -# Check if .env file exists -if [ ! -f .env ]; then - echo "Warning: .env file not found" - echo "Creating .env file..." - echo "OPENAI_API_KEY=" > .env - echo "PORT=8888" >> .env - echo "OUTPUT_DIR=./output" >> .env - echo "" - echo "Please edit .env and add your OPENAI_API_KEY" - echo "" -fi - -# Check if node_modules exists -if [ ! -d "node_modules" ]; then - echo "Installing dependencies..." - npm install - echo "" -fi - -# Kill any process using port 8888 -echo "Checking port 8888..." -npx kill-port 8888 2>/dev/null - -echo "" -echo "Starting server on http://localhost:8888" -echo "Press Ctrl+C to stop the server" -echo "" -echo "==========================================" -echo "" - -# Start the server -npm run server +#!/bin/bash + +# Video to MP3 Transcriptor Server Starter +# This script starts the API server on port 8888 + +echo "==========================================" +echo "Video to MP3 Transcriptor API" +echo "==========================================" +echo "" + +# Check if node is installed +if ! command -v node &> /dev/null +then + echo "Error: Node.js is not installed" + echo "Please install Node.js from https://nodejs.org/" + exit 1 +fi + +# Check if npm is installed +if ! command -v npm &> /dev/null +then + echo "Error: npm is not installed" + echo "Please install npm" + exit 1 +fi + +# Check if .env file exists +if [ ! -f .env ]; then + echo "Warning: .env file not found" + echo "Creating .env file..." + echo "OPENAI_API_KEY=" > .env + echo "PORT=8888" >> .env + echo "OUTPUT_DIR=./output" >> .env + echo "" + echo "Please edit .env and add your OPENAI_API_KEY" + echo "" +fi + +# Check if node_modules exists +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + npm install + echo "" +fi + +# Kill any process using port 8888 +echo "Checking port 8888..." +npx kill-port 8888 2>/dev/null + +echo "" +echo "Starting server on http://localhost:8888" +echo "Press Ctrl+C to stop the server" +echo "" +echo "==========================================" +echo "" + +# Start the server +npm run server