From 849412c3bd3e83a966dc1be77efafe9daefa1ee6 Mon Sep 17 00:00:00 2001 From: StillHammer Date: Mon, 24 Nov 2025 11:40:23 +0800 Subject: [PATCH] Initial commit: Video to MP3 Transcriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YouTube video/playlist download as MP3 (yt-dlp) - Audio transcription with OpenAI (gpt-4o-transcribe, whisper-1) - Translation with GPT-4o-mini (chunking for long texts) - Web interface with progress bars and drag & drop - CLI and REST API interfaces - Linux shell scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 11 + .gitignore | 43 + README.md | 235 +++++ package-lock.json | 1834 +++++++++++++++++++++++++++++++++ package.json | 34 + public/app.js | 636 ++++++++++++ public/index.html | 345 +++++++ public/style.css | 685 ++++++++++++ scripts/download.sh | 20 + scripts/info.sh | 18 + scripts/process.sh | 32 + scripts/server.sh | 10 + scripts/transcribe.sh | 32 + src/cli.js | 169 +++ src/server.js | 746 ++++++++++++++ src/services/transcription.js | 178 ++++ src/services/translation.js | 270 +++++ src/services/youtube.js | 239 +++++ 18 files changed, 5537 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 scripts/download.sh create mode 100644 scripts/info.sh create mode 100644 scripts/process.sh create mode 100644 scripts/server.sh create mode 100644 scripts/transcribe.sh create mode 100644 src/cli.js create mode 100644 src/server.js create mode 100644 src/services/transcription.js create mode 100644 src/services/translation.js create mode 100644 src/services/youtube.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7da55bc --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# OpenAI API Key for Whisper transcription +OPENAI_API_KEY=your_openai_api_key_here + +# Anthropic API Key for Claude Haiku translation (optional) +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Server port (optional, default: 3000) +PORT=3000 + +# Output directory (optional, default: ./output) +OUTPUT_DIR=./output diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d05cb67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Dependencies +node_modules/ + +# Environment +.env + +# Output directory +output/ + +# Audio files +*.mp3 +*.wav +*.m4a +*.ogg +*.flac +*.aac + +# Video files +*.mp4 +*.webm +*.mkv +*.avi + +# Text/transcription files +*.txt + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b14540 --- /dev/null +++ b/README.md @@ -0,0 +1,235 @@ +# Video to MP3 Transcriptor + +Download YouTube videos/playlists to MP3 and transcribe them using OpenAI Whisper API. + +## Features + +- Download single YouTube videos as MP3 +- Download entire playlists as MP3 +- Transcribe audio files using OpenAI Whisper API +- CLI interface for quick operations +- REST API for integration with other systems + +## Prerequisites + +- **Node.js** 18+ +- **yt-dlp** installed on your system +- **ffmpeg** installed (for audio conversion) +- **OpenAI API key** (for transcription) + +### Installing yt-dlp + +```bash +# Windows (winget) +winget install yt-dlp + +# macOS +brew install yt-dlp + +# Linux +sudo apt install yt-dlp +# or +pip install yt-dlp +``` + +### Installing ffmpeg + +```bash +# Windows (winget) +winget install ffmpeg + +# macOS +brew install ffmpeg + +# Linux +sudo apt install ffmpeg +``` + +## Installation + +```bash +# Clone and install +cd videotoMP3Transcriptor +npm install + +# Configure environment +cp .env.example .env +# Edit .env and add your OPENAI_API_KEY +``` + +## Usage + +### CLI + +```bash +# Download a video as MP3 +npm run cli download "https://youtube.com/watch?v=VIDEO_ID" + +# Download a playlist +npm run cli download "https://youtube.com/playlist?list=PLAYLIST_ID" + +# Download with custom output directory +npm run cli download "URL" -o ./my-folder + +# Get info about a video/playlist +npm run cli info "URL" + +# Transcribe an existing MP3 +npm run cli transcribe ./output/video.mp3 + +# Transcribe with specific language +npm run cli transcribe ./output/video.mp3 -l fr + +# Transcribe with specific model +npm run cli transcribe ./output/video.mp3 -m gpt-4o-mini-transcribe + +# Download AND transcribe +npm run cli process "URL" + +# Download and transcribe with options +npm run cli process "URL" -l en -m gpt-4o-transcribe +``` + +### Linux Scripts + +Convenience scripts are available in the `scripts/` directory: + +```bash +# Make scripts executable (first time only) +chmod +x scripts/*.sh + +# Download video/playlist +./scripts/download.sh "https://youtube.com/watch?v=VIDEO_ID" + +# Transcribe a file +./scripts/transcribe.sh ./output/video.mp3 fr + +# Download + transcribe +./scripts/process.sh "https://youtube.com/watch?v=VIDEO_ID" en + +# Start the API server +./scripts/server.sh + +# Get video info +./scripts/info.sh "https://youtube.com/watch?v=VIDEO_ID" +``` + +### API Server + +```bash +# Start the server +npm run server +``` + +Server runs on `http://localhost:3000` by default. + +#### Endpoints + +##### GET /health +Health check endpoint. + +##### GET /info?url=YOUTUBE_URL +Get info about a video or playlist. + +```bash +curl "http://localhost:3000/info?url=https://youtube.com/watch?v=VIDEO_ID" +``` + +##### POST /download +Download video(s) as MP3. + +```bash +curl -X POST http://localhost:3000/download \ + -H "Content-Type: application/json" \ + -d '{"url": "https://youtube.com/watch?v=VIDEO_ID"}' +``` + +##### POST /transcribe +Transcribe an existing audio file. + +```bash +curl -X POST http://localhost:3000/transcribe \ + -H "Content-Type: application/json" \ + -d '{"filePath": "./output/video.mp3", "language": "en"}' +``` + +##### POST /process +Download and transcribe in one call. + +```bash +curl -X POST http://localhost:3000/process \ + -H "Content-Type: application/json" \ + -d '{"url": "https://youtube.com/watch?v=VIDEO_ID", "language": "en", "format": "txt"}' +``` + +##### GET /files-list +List all downloaded files. + +##### GET /files/:filename +Download/stream a specific file. + +## Configuration + +Environment variables (`.env`): + +| Variable | Description | Default | +|----------|-------------|---------| +| `OPENAI_API_KEY` | Your OpenAI API key | Required for transcription | +| `PORT` | Server port | 3000 | +| `OUTPUT_DIR` | Download directory | ./output | + +## Transcription Models + +| Model | Description | Formats | +|-------|-------------|---------| +| `gpt-4o-transcribe` | Best quality, latest GPT-4o (default) | txt, json | +| `gpt-4o-mini-transcribe` | Faster, cheaper, good quality | txt, json | +| `whisper-1` | Legacy Whisper model | txt, json, srt, vtt | + +## Transcription Formats + +- `txt` - Plain text (all models) +- `json` - JSON response (all models) +- `srt` - SubRip subtitles (whisper-1 only) +- `vtt` - WebVTT subtitles (whisper-1 only) + +## Language Codes + +Common language codes for the `-l` option: +- `en` - English +- `fr` - French +- `es` - Spanish +- `de` - German +- `it` - Italian +- `pt` - Portuguese +- `zh` - Chinese +- `ja` - Japanese +- `ko` - Korean +- `ru` - Russian + +Leave empty for auto-detection. + +## Project Structure + +``` +videotoMP3Transcriptor/ +├── src/ +│ ├── services/ +│ │ ├── youtube.js # YouTube download service +│ │ └── transcription.js # OpenAI transcription service +│ ├── cli.js # CLI entry point +│ └── server.js # Express API server +├── scripts/ # Linux convenience scripts +│ ├── download.sh # Download video/playlist +│ ├── transcribe.sh # Transcribe audio file +│ ├── process.sh # Download + transcribe +│ ├── server.sh # Start API server +│ └── info.sh # Get video info +├── output/ # Downloaded files +├── .env # Configuration +└── package.json +``` + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bc892aa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1834 @@ +{ + "name": "video-to-mp3-transcriptor", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "video-to-mp3-transcriptor", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.70.1", + "commander": "^12.1.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "multer": "^2.0.2", + "openai": "^4.67.0", + "youtube-dl-exec": "^3.0.7" + }, + "bin": { + "ytmp3": "src/cli.js" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.70.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.70.1.tgz", + "integrity": "sha512-AGEhifuvE22VxfQ5ROxViTgM8NuVQzEvqcN8bttR4AP24ythmNE/cL/SrOz79xiv7/osrsmCyErjsistJi7Z8A==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@kikobeats/time-span": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@kikobeats/time-span/-/time-span-1.0.11.tgz", + "integrity": "sha512-S+msolgD9aPVoJ+ZomVD0WSKm+qJBKvJimzwq8dMvlGKbIPsAyEWhHHdSRuQT3g2VpDIctvbi9nU++kN/VPZaw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/binary-version": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/binary-version/-/binary-version-7.1.0.tgz", + "integrity": "sha512-Iy//vPc3ANPNlIWd242Npqc8MK0a/i4kVcHDlDA6HNMv5zMxz4ulIFhOSYJVKw/8AbHdHy0CnGYEt1QqSXxPsw==", + "license": "MIT", + "dependencies": { + "execa": "^8.0.1", + "find-versions": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version-check": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/binary-version-check/-/binary-version-check-6.1.0.tgz", + "integrity": "sha512-REKdLKmuViV2WrtWXvNSiPX04KbIjfUV3Cy8batUeOg+FtmowavzJorfFhWq95cVJzINnL/44ixP26TrdJZACA==", + "license": "MIT", + "dependencies": { + "binary-version": "^7.1.0", + "semver": "^7.6.0", + "semver-truncate": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dargs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", + "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/debug-logfmt": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/debug-logfmt/-/debug-logfmt-1.4.7.tgz", + "integrity": "sha512-NzGmPp2Fru8KerWcg4zfiPCC1rspLUPqfH5Duz/ZF49CqO97odSx7eFjBNiOQzNQYfvpEEPrxNjyA436lITQkQ==", + "license": "MIT", + "dependencies": { + "@kikobeats/time-span": "~1.0.5", + "null-prototype-object": "~1.2.2", + "pretty-ms": "~7.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "license": "MIT", + "dependencies": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unix": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/is-unix/-/is-unix-2.0.14.tgz", + "integrity": "sha512-ZE+Iq0h1pxZu/IGsBKobH5PZ0L3ylv7WHEmKiRG8kEzue6f+w0i3ckwnDY7Ckej2jjq1c7NDYljEkNqOxv4w9A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/make-asynchronous": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.0.1.tgz", + "integrity": "sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ==", + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/null-prototype-object": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/null-prototype-object/-/null-prototype-object-1.2.5.tgz", + "integrity": "sha512-YAPMPwBVlXXmIx/eIHx/KwIL1Bsd8I+YHQdFpW0Ydvez6vu5Bx2CaP4GrEnH5c1huVWZD9MqEuFwAJoBMm5LJQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver-truncate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/semver-truncate/-/semver-truncate-3.0.0.tgz", + "integrity": "sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyspawn": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/tinyspawn/-/tinyspawn-1.5.5.tgz", + "integrity": "sha512-Wq3kFq9V0l//CkvIxEw5kyWIUAW+zfgg2h+FbR/xOeJGR7kp7wKAXbMVXue1P0GaNByRPRQxW670Y3Xzx9bWxA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-worker": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", + "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/youtube-dl-exec": { + "version": "3.0.27", + "resolved": "https://registry.npmjs.org/youtube-dl-exec/-/youtube-dl-exec-3.0.27.tgz", + "integrity": "sha512-2+4alMrjd0vFOP+899ZtLY2xtdmgTkVgXawgTmvS89sdm+7vPOBefHoFEa+YNmaQtoVxLY6bl9CFiZn0vugIiA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "binary-version-check": "~6.1.0", + "dargs": "~7.0.0", + "debug-logfmt": "~1.4.0", + "is-unix": "~2.0.10", + "tinyspawn": "~1.5.0" + }, + "engines": { + "node": ">= 18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e6d7317 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "video-to-mp3-transcriptor", + "version": "1.0.0", + "description": "Download YouTube videos/playlists to MP3 and transcribe them using OpenAI Whisper API", + "main": "src/index.js", + "type": "module", + "bin": { + "ytmp3": "./src/cli.js" + }, + "scripts": { + "start": "node src/index.js", + "cli": "node src/cli.js", + "server": "node src/server.js" + }, + "keywords": [ + "youtube", + "mp3", + "transcription", + "whisper", + "openai" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.70.1", + "commander": "^12.1.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "multer": "^2.0.2", + "openai": "^4.67.0", + "youtube-dl-exec": "^3.0.7" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..ce1a8ff --- /dev/null +++ b/public/app.js @@ -0,0 +1,636 @@ +// 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); + }; +}); + +// ==================== 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); + } +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5e4f8a4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,345 @@ + + + + + + Video to MP3 Transcriptor + + + +
    +
    +

    Video to MP3 Transcriptor

    +

    Download YouTube videos, transcribe and translate them

    +
    + + + + + +
    +

    Download YouTube Video/Playlist

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

    Transcribe Audio File

    +
    +
    +
    + + + + + +
    +

    Drag & drop audio files here

    +

    or click to select files

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

    Download + Transcribe

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

    Translate Text

    + + +
    + + +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + + +
    +
    + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..5b688c8 --- /dev/null +++ b/public/style.css @@ -0,0 +1,685 @@ +* { + 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; +} + +/* 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; +} diff --git a/scripts/download.sh b/scripts/download.sh new file mode 100644 index 0000000..ee2710a --- /dev/null +++ b/scripts/download.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Download YouTube video/playlist as MP3 +# Usage: ./download.sh [output_dir] + +cd "$(dirname "$0")/.." + +URL="$1" +OUTPUT_DIR="${2:-./output}" + +if [ -z "$URL" ]; then + echo "Usage: ./download.sh [output_dir]" + echo "" + echo "Examples:" + echo " ./download.sh 'https://youtube.com/watch?v=VIDEO_ID'" + echo " ./download.sh 'https://youtube.com/playlist?list=PLAYLIST_ID'" + echo " ./download.sh 'https://youtube.com/watch?v=VIDEO_ID' ./my-folder" + exit 1 +fi + +npm run cli download "$URL" -o "$OUTPUT_DIR" diff --git a/scripts/info.sh b/scripts/info.sh new file mode 100644 index 0000000..2a6ff65 --- /dev/null +++ b/scripts/info.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Get info about a YouTube video/playlist +# Usage: ./info.sh + +cd "$(dirname "$0")/.." + +URL="$1" + +if [ -z "$URL" ]; then + echo "Usage: ./info.sh " + echo "" + echo "Examples:" + echo " ./info.sh 'https://youtube.com/watch?v=VIDEO_ID'" + echo " ./info.sh 'https://youtube.com/playlist?list=PLAYLIST_ID'" + exit 1 +fi + +npm run cli info "$URL" diff --git a/scripts/process.sh b/scripts/process.sh new file mode 100644 index 0000000..153ef66 --- /dev/null +++ b/scripts/process.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Download AND transcribe a YouTube video/playlist +# Usage: ./process.sh [language] [model] + +cd "$(dirname "$0")/.." + +URL="$1" +LANGUAGE="${2:-}" +MODEL="${3:-gpt-4o-transcribe}" + +if [ -z "$URL" ]; then + echo "Usage: ./process.sh [language] [model]" + echo "" + echo "Languages: en, fr, es, de, it, pt, zh, ja, ko, ru, etc." + echo "Models: gpt-4o-transcribe (default), gpt-4o-mini-transcribe, whisper-1" + echo "" + echo "Examples:" + echo " ./process.sh 'https://youtube.com/watch?v=VIDEO_ID'" + echo " ./process.sh 'https://youtube.com/watch?v=VIDEO_ID' fr" + echo " ./process.sh 'https://youtube.com/watch?v=VIDEO_ID' en gpt-4o-mini-transcribe" + exit 1 +fi + +ARGS="\"$URL\"" +if [ -n "$LANGUAGE" ]; then + ARGS="$ARGS -l $LANGUAGE" +fi +if [ -n "$MODEL" ] && [ "$MODEL" != "gpt-4o-transcribe" ]; then + ARGS="$ARGS -m $MODEL" +fi + +eval "npm run cli process $ARGS" diff --git a/scripts/server.sh b/scripts/server.sh new file mode 100644 index 0000000..cd3a187 --- /dev/null +++ b/scripts/server.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Start the API server +# Usage: ./server.sh [port] + +cd "$(dirname "$0")/.." + +PORT="${1:-3000}" + +export PORT="$PORT" +npm run server diff --git a/scripts/transcribe.sh b/scripts/transcribe.sh new file mode 100644 index 0000000..e042801 --- /dev/null +++ b/scripts/transcribe.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Transcribe an audio file +# Usage: ./transcribe.sh [language] [model] + +cd "$(dirname "$0")/.." + +FILE="$1" +LANGUAGE="${2:-}" +MODEL="${3:-gpt-4o-transcribe}" + +if [ -z "$FILE" ]; then + echo "Usage: ./transcribe.sh [language] [model]" + echo "" + echo "Languages: en, fr, es, de, it, pt, zh, ja, ko, ru, etc." + echo "Models: gpt-4o-transcribe (default), gpt-4o-mini-transcribe, whisper-1" + echo "" + echo "Examples:" + echo " ./transcribe.sh ./output/video.mp3" + echo " ./transcribe.sh ./output/video.mp3 fr" + echo " ./transcribe.sh ./output/video.mp3 en gpt-4o-mini-transcribe" + exit 1 +fi + +ARGS="$FILE" +if [ -n "$LANGUAGE" ]; then + ARGS="$ARGS -l $LANGUAGE" +fi +if [ -n "$MODEL" ] && [ "$MODEL" != "gpt-4o-transcribe" ]; then + ARGS="$ARGS -m $MODEL" +fi + +npm run cli transcribe $ARGS diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..aa1368a --- /dev/null +++ b/src/cli.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import dotenv from 'dotenv'; +import path from 'path'; +import { download, downloadVideo, downloadPlaylist, getInfo } from './services/youtube.js'; +import { transcribeFile, transcribeAndSave, transcribeMultiple, getAvailableModels } from './services/transcription.js'; + +// Load environment variables +dotenv.config(); + +const program = new Command(); + +program + .name('ytmp3') + .description('Download YouTube videos/playlists to MP3 and transcribe them') + .version('1.0.0'); + +// Download command +program + .command('download ') + .alias('dl') + .description('Download a YouTube video or playlist as MP3') + .option('-o, --output ', 'Output directory', './output') + .action(async (url, options) => { + try { + console.log('Fetching video info...'); + const result = await download(url, { outputDir: options.output }); + + console.log('\n--- Download Complete ---'); + if (result.playlistTitle) { + console.log(`Playlist: ${result.playlistTitle}`); + } + console.log(`Downloaded: ${result.successCount}/${result.totalVideos} videos`); + + result.videos.forEach(v => { + if (v.success) { + console.log(` ✓ ${v.title}`); + } else { + console.log(` ✗ ${v.title} - ${v.error}`); + } + }); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } + }); + +// Transcribe command (from existing MP3) +program + .command('transcribe ') + .alias('tr') + .description('Transcribe an existing audio file') + .option('-l, --language ', 'Language code (e.g., en, fr, zh)') + .option('-f, --format ', 'Output format (txt, srt, vtt)', 'txt') + .option('-m, --model ', 'Transcription model (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1)', 'gpt-4o-transcribe') + .action(async (file, options) => { + try { + if (!process.env.OPENAI_API_KEY) { + console.error('Error: OPENAI_API_KEY not set in environment'); + process.exit(1); + } + + console.log(`Transcribing: ${file}`); + const result = await transcribeAndSave(file, { + language: options.language, + responseFormat: options.format === 'txt' ? 'text' : options.format, + outputFormat: options.format, + model: options.model, + }); + + console.log('\n--- Transcription Complete ---'); + console.log(`Model: ${result.model}`); + console.log(`Output: ${result.transcriptionPath}`); + console.log('\nPreview:'); + console.log(result.text.substring(0, 500) + (result.text.length > 500 ? '...' : '')); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } + }); + +// Download + Transcribe command +program + .command('process ') + .alias('p') + .description('Download and transcribe a YouTube video or playlist') + .option('-o, --output ', 'Output directory', './output') + .option('-l, --language ', 'Language code for transcription') + .option('-f, --format ', 'Transcription format (txt, srt, vtt)', 'txt') + .option('-m, --model ', 'Transcription model (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1)', 'gpt-4o-transcribe') + .action(async (url, options) => { + try { + if (!process.env.OPENAI_API_KEY) { + console.error('Error: OPENAI_API_KEY not set in environment'); + process.exit(1); + } + + // Step 1: Download + console.log('Step 1: Downloading...'); + const downloadResult = await download(url, { outputDir: options.output }); + + console.log(`Downloaded: ${downloadResult.successCount}/${downloadResult.totalVideos} videos\n`); + + // Step 2: Transcribe + console.log(`Step 2: Transcribing with ${options.model}...`); + const successfulDownloads = downloadResult.videos.filter(v => v.success); + const filePaths = successfulDownloads.map(v => v.filePath); + + const transcribeResult = await transcribeMultiple(filePaths, { + language: options.language, + responseFormat: options.format === 'txt' ? 'text' : options.format, + outputFormat: options.format, + model: options.model, + }); + + console.log('\n--- Process Complete ---'); + if (downloadResult.playlistTitle) { + console.log(`Playlist: ${downloadResult.playlistTitle}`); + } + console.log(`Downloaded: ${downloadResult.successCount}/${downloadResult.totalVideos}`); + console.log(`Transcribed: ${transcribeResult.successCount}/${transcribeResult.totalFiles}`); + + transcribeResult.results.forEach(r => { + if (r.success) { + console.log(` ✓ ${path.basename(r.transcriptionPath)}`); + } else { + console.log(` ✗ ${path.basename(r.filePath)} - ${r.error}`); + } + }); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } + }); + +// Info command +program + .command('info ') + .description('Get info about a YouTube video or playlist') + .action(async (url) => { + try { + const info = await getInfo(url); + + console.log('\n--- Video/Playlist Info ---'); + console.log(`Title: ${info.title}`); + console.log(`Type: ${info._type || 'video'}`); + + if (info._type === 'playlist') { + console.log(`Videos: ${info.entries?.length || 0}`); + if (info.entries) { + info.entries.slice(0, 10).forEach((e, i) => { + console.log(` ${i + 1}. ${e.title}`); + }); + if (info.entries.length > 10) { + console.log(` ... and ${info.entries.length - 10} more`); + } + } + } else { + console.log(`Duration: ${Math.floor(info.duration / 60)}:${String(info.duration % 60).padStart(2, '0')}`); + console.log(`Channel: ${info.channel}`); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } + }); + +program.parse(); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..804aca8 --- /dev/null +++ b/src/server.js @@ -0,0 +1,746 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import path from 'path'; +import fs from 'fs'; +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'; + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3000; +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.')); + } + } +}); + +app.use(cors()); +app.use(express.json()); + +// Serve static files (HTML interface) +const __dirname = path.dirname(new URL(import.meta.url).pathname); +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) + */ +app.get('/download-stream', async (req, res) => { + const { url } = 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: 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, outputDir?: string } + */ +app.post('/download', async (req, res) => { + try { + const { url, outputDir = OUTPUT_DIR } = req.body; + + 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 } + */ +app.post('/transcribe', async (req, res) => { + try { + const { filePath, language, format = 'txt', model = 'gpt-4o-transcribe' } = 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, + }); + + 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 + */ +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-transcribe' } = 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, + }); + + 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 }); + } +}); + +/** + * GET /process-stream + * Download and transcribe with SSE progress updates + * Query: url, language?, model? + */ +app.get('/process-stream', async (req, res) => { + const { url, language, model = 'gpt-4o-transcribe' } = req.query; + + if (!url) { + return res.status(400).json({ error: 'URL parameter required' }); + } + + if (!process.env.OPENAI_API_KEY) { + return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); + } + + // Set up SSE + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + const sendEvent = (event, data) => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + const startTime = Date.now(); + let 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: 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, + }); + 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 } + */ +app.post('/process', async (req, res) => { + try { + const { url, language, format = 'txt', outputDir = OUTPUT_DIR, model = 'gpt-4o-transcribe' } = req.body; + + 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, + }); + + // 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 + */ +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 } = 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); + 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 }); + } +}); + +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + console.log('\nEndpoints:'); + console.log(' GET /health - Health check'); + console.log(' GET /info?url= - Get video/playlist info'); + console.log(' POST /download - Download as MP3'); + console.log(' POST /transcribe - Transcribe audio file'); + console.log(' POST /process - Download + transcribe'); + console.log(' GET /files-list - List downloaded files'); + console.log(' GET /files/ - Serve downloaded files'); +}); diff --git a/src/services/transcription.js b/src/services/transcription.js new file mode 100644 index 0000000..f0df30d --- /dev/null +++ b/src/services/transcription.js @@ -0,0 +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-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 new file mode 100644 index 0000000..d4a1636 --- /dev/null +++ b/src/services/translation.js @@ -0,0 +1,270 @@ +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) + */ +export async function translateFile(filePath, targetLang, sourceLang = 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( + 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, 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); + 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 new file mode 100644 index 0000000..4e2021f --- /dev/null +++ b/src/services/youtube.js @@ -0,0 +1,239 @@ +import youtubedl from 'youtube-dl-exec'; +import path from 'path'; +import fs from 'fs'; + +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 youtubedl(targetUrl, { + dumpSingleJson: true, + noDownload: true, + noWarnings: true, + flatPlaylist: true, + }); + 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 youtubedl(url, { + dumpSingleJson: true, + noDownload: true, + noWarnings: true, + }); + + const title = sanitizeFilename(info.title); + const outputPath = path.join(outputDir, `${title}.mp3`); + + // Download and convert to MP3 with progress + const subprocess = youtubedl.exec(url, { + extractAudio: true, + audioFormat: 'mp3', + audioQuality: 0, + output: outputPath, + noWarnings: true, + newline: true, + }); + + // Parse progress from yt-dlp output + if (onDownloadProgress && subprocess.stdout) { + subprocess.stdout.on('data', (data) => { + const line = data.toString(); + // Parse progress: [download] 45.2% of 10.5MiB at 1.2MiB/s ETA 00:05 + const progressMatch = line.match(/\[download\]\s+(\d+\.?\d*)%/); + const etaMatch = line.match(/ETA\s+(\d+:\d+)/); + const speedMatch = line.match(/at\s+([\d.]+\w+\/s)/); + + if (progressMatch) { + onDownloadProgress({ + percent: parseFloat(progressMatch[1]), + eta: etaMatch ? etaMatch[1] : null, + speed: speedMatch ? speedMatch[1] : null, + title: info.title, + }); + } + }); + } + + await subprocess; + + return { + success: true, + title: info.title, + 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, + }; + } +}