Initial commit — yt-dlf YouTube downloader & transcript extractor
SvelteKit web frontend for yt-dlp and ffmpeg. Paste a YouTube URL to download best-quality video, subtitles (original + EN + DE), and thumbnail. Subtitles are converted to clean Markdown. Optional audio extraction to MP3. Supports ZIP & Send mode: downloaded files are packed into a ZIP and delivered to the browser, with optional server-side cleanup after delivery. An extra directory (ZIP_EXTRA_DIR) can be bundled into every ZIP. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
3544a89609
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
87
INSTALL.md
Normal file
87
INSTALL.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Deployment
|
||||
|
||||
## What's in the package
|
||||
|
||||
```
|
||||
build/ ← compiled SvelteKit server (self-contained, no npm needed)
|
||||
scripts/
|
||||
subtitle_to_markdown.py ← standalone subtitle converter CLI
|
||||
create_zip.py ← ZIP creation helper (used internally by the server)
|
||||
start.sh ← startup script
|
||||
README.md
|
||||
INSTALL.md
|
||||
```
|
||||
|
||||
## Prerequisites on the Linux server
|
||||
|
||||
Node.js 18 or newer, Python 3, yt-dlp, and ffmpeg must be installed.
|
||||
|
||||
```bash
|
||||
# ffmpeg, Node.js, and Python via apt
|
||||
sudo apt install ffmpeg nodejs python3
|
||||
|
||||
# yt-dlp (latest release)
|
||||
sudo curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
|
||||
-o /usr/local/bin/yt-dlp
|
||||
sudo chmod +x /usr/local/bin/yt-dlp
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
tar -xzf yt-dlf.tar.gz
|
||||
cd yt-dlf
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
The server listens on port 3000 by default. Open `http://your-server:3000` in a browser.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Edit `start.sh` to configure the variables below, or pass them directly on the command line.
|
||||
|
||||
### General
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PORT` | `3000` | Port to listen on |
|
||||
| `ORIGIN` | `http://localhost:PORT` | Public URL of the server — set this in production |
|
||||
| `YTDLP_PATH` | `yt-dlp` | Full path to yt-dlp binary if not on PATH |
|
||||
| `FFMPEG_PATH` | `ffmpeg` | Full path to ffmpeg binary if not on PATH |
|
||||
| `DOWNLOAD_DIR` | `~/YouTube` | Directory where downloaded files are stored on the server |
|
||||
|
||||
### ZIP & Send mode
|
||||
|
||||
When `ZIP_AND_SEND=true`, all downloaded files are packed into a ZIP and offered as a browser download instead of (only) being saved on the server. A random prefix is added to the temporary directory name to avoid collisions when multiple users download the same video simultaneously.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `ZIP_AND_SEND` | `false` | Enable ZIP & Send mode |
|
||||
| `ZIP_EXTRA_DIR` | _(none)_ | Optional directory whose files are added to every ZIP alongside the downloaded files. Subdirectory structure is preserved. |
|
||||
| `DELETE_AFTER_SEND` | `false` | Delete files from the server after the ZIP has been sent to the browser |
|
||||
| `DELETE_DELAY` | `0` | Seconds to wait before deleting files after the ZIP has been sent |
|
||||
|
||||
### Examples
|
||||
|
||||
Custom port and public URL:
|
||||
|
||||
```bash
|
||||
PORT=8080 ORIGIN=http://myserver:8080 ./start.sh
|
||||
```
|
||||
|
||||
ZIP & Send with cleanup after 60 seconds:
|
||||
|
||||
```bash
|
||||
ZIP_AND_SEND=true DELETE_AFTER_SEND=true DELETE_DELAY=60 ./start.sh
|
||||
```
|
||||
|
||||
ZIP & Send with extra files bundled into every download:
|
||||
|
||||
```bash
|
||||
ZIP_AND_SEND=true ZIP_EXTRA_DIR=/opt/yt-dlf/extras ./start.sh
|
||||
```
|
||||
61
README.md
Normal file
61
README.md
Normal file
@ -0,0 +1,61 @@
|
||||
# yt-dlf
|
||||
|
||||
YouTube Downloader & Transcript Extractor — a local web frontend for `yt-dlp` and `ffmpeg`.
|
||||
|
||||
## Features
|
||||
|
||||
- Paste a YouTube URL and download the best available video quality
|
||||
- Subtitles downloaded automatically in the video's original language, English, and German (where available)
|
||||
- Subtitles converted to clean Markdown (timestamps stripped, text deduplicated and paragraph-wrapped)
|
||||
- Optional audio extraction to MP3 via ffmpeg
|
||||
- Real-time progress log streamed to the browser
|
||||
- Files saved to `~/YouTube/<video title>/`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
brew install yt-dlp ffmpeg
|
||||
```
|
||||
|
||||
Node.js 16+ is required for the web app (tested with Node 26).
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
yt-dlf/
|
||||
├── scripts/
|
||||
│ └── subtitle_to_markdown.py # standalone CLI converter
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ └── subtitle.js # JS conversion module (used by web app)
|
||||
│ └── routes/
|
||||
│ ├── api/download/
|
||||
│ │ └── +server.js # SSE endpoint — runs yt-dlp / ffmpeg
|
||||
│ ├── +layout.svelte
|
||||
│ └── +page.svelte # UI
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
## Running the web app
|
||||
|
||||
```bash
|
||||
npm install # first time only
|
||||
npm run dev # starts on http://localhost:5173
|
||||
```
|
||||
|
||||
To use a custom port:
|
||||
|
||||
```bash
|
||||
PORT=8080 npm run dev
|
||||
```
|
||||
|
||||
## Standalone subtitle converter
|
||||
|
||||
Convert a `.vtt` or `.srt` subtitle file to Markdown directly from the terminal:
|
||||
|
||||
```bash
|
||||
./scripts/subtitle_to_markdown.py video.en.vtt
|
||||
./scripts/subtitle_to_markdown.py video.en.vtt output.md
|
||||
```
|
||||
|
||||
Output is saved next to the input file (same name, `.md` extension) unless a second argument is given.
|
||||
13
jsconfig.json
Normal file
13
jsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
2542
package-lock.json
generated
Normal file
2542
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "yt-dlf",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"svelte": "^5.55.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^8.0.0"
|
||||
}
|
||||
}
|
||||
34
scripts/create_zip.py
Executable file
34
scripts/create_zip.py
Executable file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a ZIP archive from a directory, with an optional extra directory.
|
||||
|
||||
Usage: create_zip.py <source_dir> <output.zip> <archive_dir_name> [extra_dir]
|
||||
|
||||
Files from source_dir and (if given) extra_dir are both stored under
|
||||
archive_dir_name/ in the ZIP, regardless of the actual directory names on disk.
|
||||
"""
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 4:
|
||||
print(f'Usage: {sys.argv[0]} <source_dir> <output.zip> <archive_dir_name> [extra_dir]', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
source_dir = Path(sys.argv[1])
|
||||
zip_path = sys.argv[2]
|
||||
archive_name = sys.argv[3]
|
||||
extra_dir = Path(sys.argv[4]) if len(sys.argv) >= 5 else None
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for file_path in sorted(source_dir.rglob('*')):
|
||||
if file_path.is_file():
|
||||
arcname = Path(archive_name) / file_path.relative_to(source_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
if extra_dir and extra_dir.is_dir():
|
||||
for file_path in sorted(extra_dir.rglob('*')):
|
||||
if file_path.is_file():
|
||||
arcname = Path(archive_name) / file_path.relative_to(extra_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
print(f'Created: {zip_path}')
|
||||
104
scripts/subtitle_to_markdown.py
Executable file
104
scripts/subtitle_to_markdown.py
Executable file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert VTT or SRT subtitle files to Markdown plain text."""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def strip_html(text):
|
||||
return re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
|
||||
def parse_vtt(content):
|
||||
blocks = re.split(r'\n{2,}', content.strip())
|
||||
cues = []
|
||||
for block in blocks:
|
||||
lines = block.strip().splitlines()
|
||||
if not lines:
|
||||
continue
|
||||
if lines[0].startswith('WEBVTT'):
|
||||
continue
|
||||
if lines[0].startswith('NOTE') or lines[0].startswith('STYLE'):
|
||||
continue
|
||||
ts_idx = next((i for i, l in enumerate(lines) if '-->' in l), None)
|
||||
if ts_idx is None:
|
||||
continue
|
||||
text = ' '.join(
|
||||
strip_html(l).strip()
|
||||
for l in lines[ts_idx + 1:]
|
||||
if strip_html(l).strip()
|
||||
)
|
||||
if text:
|
||||
cues.append(text)
|
||||
return cues
|
||||
|
||||
|
||||
def parse_srt(content):
|
||||
blocks = re.split(r'\n{2,}', content.strip())
|
||||
cues = []
|
||||
for block in blocks:
|
||||
lines = block.strip().splitlines()
|
||||
text_lines = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if re.match(r'^\d+$', line):
|
||||
continue
|
||||
if re.match(r'\d{2}:\d{2}:\d{2}[,\.]\d{3}\s*-->', line):
|
||||
continue
|
||||
cleaned = strip_html(line)
|
||||
if cleaned:
|
||||
text_lines.append(cleaned)
|
||||
if text_lines:
|
||||
cues.append(' '.join(text_lines))
|
||||
return cues
|
||||
|
||||
|
||||
def deduplicate(cues):
|
||||
result = []
|
||||
prev = None
|
||||
for cue in cues:
|
||||
if cue != prev:
|
||||
result.append(cue)
|
||||
prev = cue
|
||||
return result
|
||||
|
||||
|
||||
def to_markdown(cues):
|
||||
cues = deduplicate(cues)
|
||||
if not cues:
|
||||
return ''
|
||||
full = ' '.join(cues)
|
||||
sentences = re.split(r'(?<=[.!?…])\s+', full)
|
||||
paragraphs = []
|
||||
for i in range(0, len(sentences), 8):
|
||||
paragraphs.append(' '.join(sentences[i:i + 8]))
|
||||
return '\n\n'.join(paragraphs)
|
||||
|
||||
|
||||
def convert(input_path):
|
||||
content = input_path.read_text(encoding='utf-8', errors='replace')
|
||||
suffix = input_path.suffix.lower()
|
||||
if suffix == '.vtt':
|
||||
cues = parse_vtt(content)
|
||||
elif suffix == '.srt':
|
||||
cues = parse_srt(content)
|
||||
else:
|
||||
raise ValueError(f'Unsupported format: {suffix}')
|
||||
return to_markdown(cues)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(f'Usage: {sys.argv[0]} <file.vtt|file.srt> [output.md]', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
input_path = Path(sys.argv[1])
|
||||
if not input_path.exists():
|
||||
print(f'File not found: {input_path}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
md = convert(input_path)
|
||||
|
||||
output_path = Path(sys.argv[2]) if len(sys.argv) >= 3 else input_path.with_suffix('.md')
|
||||
output_path.write_text(md, encoding='utf-8')
|
||||
print(f'Saved: {output_path}')
|
||||
12
src/app.html
Normal file
12
src/app.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/lib/index.js
Normal file
1
src/lib/index.js
Normal file
@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
13
src/lib/server/store.js
Normal file
13
src/lib/server/store.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
export const config = {
|
||||
downloadDir: process.env.DOWNLOAD_DIR ?? join(homedir(), 'YouTube'),
|
||||
zipAndSend: process.env.ZIP_AND_SEND === 'true',
|
||||
zipExtraDir: process.env.ZIP_EXTRA_DIR ?? '',
|
||||
deleteAfterSend: process.env.DELETE_AFTER_SEND === 'true',
|
||||
deleteDelay: parseInt(process.env.DELETE_DELAY ?? '0') * 1000,
|
||||
};
|
||||
|
||||
// token → { zipPath, outputDir, filename }
|
||||
export const zipStore = new Map();
|
||||
65
src/lib/subtitle.js
Normal file
65
src/lib/subtitle.js
Normal file
@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
|
||||
function stripHtml(text) {
|
||||
return text.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
function parseVtt(content) {
|
||||
const blocks = content.split(/\n{2,}/);
|
||||
const cues = [];
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split('\n');
|
||||
if (!lines.length) continue;
|
||||
if (lines[0].startsWith('WEBVTT')) continue;
|
||||
if (lines[0].startsWith('NOTE') || lines[0].startsWith('STYLE')) continue;
|
||||
const tsIdx = lines.findIndex(l => l.includes('-->'));
|
||||
if (tsIdx === -1) continue;
|
||||
const text = lines
|
||||
.slice(tsIdx + 1)
|
||||
.map(l => stripHtml(l).trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
if (text) cues.push(text);
|
||||
}
|
||||
return cues;
|
||||
}
|
||||
|
||||
function parseSrt(content) {
|
||||
const blocks = content.split(/\n{2,}/);
|
||||
const cues = [];
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split('\n');
|
||||
const textLines = lines
|
||||
.filter(l => !/^\d+$/.test(l.trim()))
|
||||
.filter(l => !/\d{2}:\d{2}:\d{2}[,.]\d{3}\s*-->/.test(l))
|
||||
.map(l => stripHtml(l).trim())
|
||||
.filter(Boolean);
|
||||
if (textLines.length) cues.push(textLines.join(' '));
|
||||
}
|
||||
return cues;
|
||||
}
|
||||
|
||||
function deduplicate(cues) {
|
||||
return cues.filter((c, i) => c !== cues[i - 1]);
|
||||
}
|
||||
|
||||
function toMarkdown(cues) {
|
||||
const deduped = deduplicate(cues);
|
||||
if (!deduped.length) return '';
|
||||
const full = deduped.join(' ');
|
||||
const sentences = full.split(/(?<=[.!?…])\s+/);
|
||||
const paragraphs = [];
|
||||
for (let i = 0; i < sentences.length; i += 8) {
|
||||
paragraphs.push(sentences.slice(i, i + 8).join(' '));
|
||||
}
|
||||
return paragraphs.join('\n\n');
|
||||
}
|
||||
|
||||
export function convertSubtitleToMarkdown(filePath) {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
if (ext === '.vtt') return toMarkdown(parseVtt(content));
|
||||
if (ext === '.srt') return toMarkdown(parseSrt(content));
|
||||
throw new Error(`Unsupported subtitle format: ${ext}`);
|
||||
}
|
||||
11
src/routes/+layout.svelte
Normal file
11
src/routes/+layout.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
348
src/routes/+page.svelte
Normal file
348
src/routes/+page.svelte
Normal file
@ -0,0 +1,348 @@
|
||||
<script>
|
||||
let url = $state('');
|
||||
let audioOnly = $state(false);
|
||||
let downloading = $state(false);
|
||||
let log = $state([]);
|
||||
let status = $state('idle');
|
||||
let logEl = $state(null);
|
||||
let zipToken = $state(null);
|
||||
let zipFilename = $state('');
|
||||
|
||||
function scrollToBottom() {
|
||||
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function startDownload() {
|
||||
if (!url.trim() || downloading) return;
|
||||
downloading = true;
|
||||
log = [];
|
||||
status = 'running';
|
||||
zipToken = null;
|
||||
|
||||
const params = new URLSearchParams({ url: url.trim(), audio: String(audioOnly) });
|
||||
const es = new EventSource(`/api/download?${params}`);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type !== 'zip-ready') log = [...log, data];
|
||||
setTimeout(scrollToBottom, 0);
|
||||
|
||||
if (data.type === 'zip-ready') {
|
||||
zipToken = data.token;
|
||||
zipFilename = data.filename;
|
||||
} else if (data.type === 'done') {
|
||||
status = 'done';
|
||||
es.close();
|
||||
downloading = false;
|
||||
} else if (data.type === 'error') {
|
||||
status = 'error';
|
||||
es.close();
|
||||
downloading = false;
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
if (status === 'running') {
|
||||
log = [...log, { type: 'error', message: 'Connection lost' }];
|
||||
status = 'error';
|
||||
downloading = false;
|
||||
}
|
||||
es.close();
|
||||
};
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') startDownload();
|
||||
}
|
||||
|
||||
function clearZip() {
|
||||
zipToken = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>yt-dlf</h1>
|
||||
<p>YouTube Downloader & Transcript Extractor</p>
|
||||
</header>
|
||||
|
||||
<section class="controls">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={url}
|
||||
placeholder="https://www.youtube.com/watch?v=…"
|
||||
disabled={downloading}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
|
||||
<div class="options">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={audioOnly} disabled={downloading} />
|
||||
Extract audio (MP3)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onclick={startDownload} disabled={downloading || !url.trim()}>
|
||||
{downloading ? 'Downloading…' : 'Download'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{#if zipToken}
|
||||
<section class="zip-section">
|
||||
<a
|
||||
class="zip-btn"
|
||||
href="/api/zip?token={zipToken}"
|
||||
download={zipFilename}
|
||||
onclick={clearZip}
|
||||
>
|
||||
Download ZIP
|
||||
</a>
|
||||
<span class="zip-name">{zipFilename}</span>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if log.length > 0}
|
||||
<section class="log-section">
|
||||
<div class="log-header">
|
||||
<span
|
||||
class="status-dot"
|
||||
class:running={status === 'running'}
|
||||
class:done={status === 'done'}
|
||||
class:error={status === 'error'}
|
||||
></span>
|
||||
<span class="status-label">
|
||||
{#if status === 'running'}Downloading…{:else if status === 'done'}Done{:else if status === 'error'}Error{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="log" bind:this={logEl}>
|
||||
{#each log as entry}
|
||||
<div class="line {entry.type}">{entry.message}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #f5f5f0;
|
||||
color: #1a1a1a;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 800;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
header p {
|
||||
color: #888;
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
input[type='url'] {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d0c8;
|
||||
border-radius: 8px;
|
||||
color: #1a1a1a;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input[type='url']:focus {
|
||||
border-color: #65d000;
|
||||
}
|
||||
|
||||
input[type='url']:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: #65d000;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 2rem;
|
||||
background: #65d000;
|
||||
color: #1a1a1a;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #54b800;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.zip-section {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f0fce8;
|
||||
border: 1.5px solid #65d000;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.zip-btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.5rem;
|
||||
background: #65d000;
|
||||
color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.zip-btn:hover {
|
||||
background: #54b800;
|
||||
}
|
||||
|
||||
.zip-name {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
background: #f0a500;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.done {
|
||||
background: #65d000;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #e03030;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.25; }
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.82rem;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #fff;
|
||||
border: 1px solid #d0d0c8;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.line {
|
||||
color: #aaa;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.line.info {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.line.done {
|
||||
color: #3a8000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line.error {
|
||||
color: #c00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line.warn {
|
||||
color: #b07000;
|
||||
}
|
||||
</style>
|
||||
169
src/routes/api/download/+server.js
Normal file
169
src/routes/api/download/+server.js
Normal file
@ -0,0 +1,169 @@
|
||||
import { spawn, execFile } from 'child_process';
|
||||
import { existsSync, readdirSync, writeFileSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { convertSubtitleToMarkdown } from '$lib/subtitle.js';
|
||||
import { config, zipStore } from '$lib/server/store.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const YTDLP = process.env.YTDLP_PATH ?? 'yt-dlp';
|
||||
const FFMPEG = process.env.FFMPEG_PATH ?? 'ffmpeg';
|
||||
|
||||
export async function GET({ url, request }) {
|
||||
const videoUrl = url.searchParams.get('url');
|
||||
const audioOnly = url.searchParams.get('audio') === 'true';
|
||||
|
||||
if (!videoUrl) {
|
||||
return new Response('Missing url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Accepts a string message or an object with extra fields
|
||||
const send = (type, messageOrData) => {
|
||||
try {
|
||||
const payload = typeof messageOrData === 'string'
|
||||
? { type, message: messageOrData }
|
||||
: { type, ...messageOrData };
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
request.signal.addEventListener('abort', () => abortController.abort());
|
||||
|
||||
runDownload(videoUrl, audioOnly, send, abortController.signal)
|
||||
.catch(err => {
|
||||
if (err.name !== 'AbortError' && err.code !== 'ABORT_ERR') {
|
||||
send('error', err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
try { controller.close(); } catch {}
|
||||
});
|
||||
},
|
||||
cancel() {}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runDownload(videoUrl, audioOnly, send, signal) {
|
||||
send('info', 'Fetching video info…');
|
||||
const { stdout } = await execFileAsync(YTDLP, [
|
||||
'--get-filename', '--output', '%(title)s', '--no-playlist', videoUrl
|
||||
], { signal });
|
||||
const title = stdout.trim();
|
||||
|
||||
const token = config.zipAndSend ? randomBytes(8).toString('hex') : null;
|
||||
const dirName = token ? `${token}-${title}` : title;
|
||||
const outputDir = join(config.downloadDir, dirName);
|
||||
const outputTemplate = join(outputDir, '%(title)s.%(ext)s');
|
||||
send('info', `Title: ${title}`);
|
||||
|
||||
const subtitlePaths = [];
|
||||
let videoPath = null;
|
||||
|
||||
await runProcess(YTDLP, [
|
||||
'--format', 'bestvideo+bestaudio/best',
|
||||
'--merge-output-format', 'mp4',
|
||||
'--output', outputTemplate,
|
||||
'--write-subs',
|
||||
'--write-auto-subs',
|
||||
'--sub-langs', 'en.*,de.*,orig',
|
||||
'--sub-format', 'vtt',
|
||||
'--write-thumbnail',
|
||||
'--sleep-requests', '1',
|
||||
'--ignore-errors',
|
||||
'--no-playlist',
|
||||
videoUrl
|
||||
], line => {
|
||||
send('log', line);
|
||||
const sub = line.match(/\[info\] Writing video subtitles to: (.+)/);
|
||||
if (sub) subtitlePaths.push(sub[1].trim());
|
||||
const merge = line.match(/\[Merger\] Merging formats into "(.+)"/);
|
||||
if (merge) videoPath = merge[1].trim();
|
||||
const dest = line.match(/\[download\] Destination: (.+\.mp4)$/);
|
||||
if (dest && !videoPath) videoPath = dest[1].trim();
|
||||
}, signal);
|
||||
|
||||
// Fallback: scan directory for subtitle files
|
||||
if (subtitlePaths.length === 0 && existsSync(outputDir)) {
|
||||
for (const f of readdirSync(outputDir)) {
|
||||
if (f.endsWith('.vtt') || f.endsWith('.srt')) {
|
||||
subtitlePaths.push(join(outputDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const subPath of subtitlePaths) {
|
||||
if (!existsSync(subPath)) continue;
|
||||
send('info', `Converting: ${basename(subPath)}`);
|
||||
try {
|
||||
const md = convertSubtitleToMarkdown(subPath);
|
||||
const mdPath = subPath.replace(/\.(vtt|srt)$/, '.md');
|
||||
writeFileSync(mdPath, md, 'utf-8');
|
||||
send('info', `Saved: ${basename(mdPath)}`);
|
||||
} catch (err) {
|
||||
send('warn', `Subtitle conversion failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (audioOnly && videoPath && existsSync(videoPath)) {
|
||||
const audioPath = videoPath.replace(/\.mp4$/, '.mp3');
|
||||
send('info', `Extracting audio: ${basename(audioPath)}`);
|
||||
await runProcess(FFMPEG, [
|
||||
'-i', videoPath, '-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-y', audioPath
|
||||
], line => send('log', line), signal);
|
||||
send('info', `Audio saved: ${basename(audioPath)}`);
|
||||
}
|
||||
|
||||
if (config.zipAndSend && token) {
|
||||
send('info', 'Creating ZIP…');
|
||||
const zipPath = join(config.downloadDir, `${token}.zip`);
|
||||
await createZip(outputDir, zipPath, title, send, signal);
|
||||
zipStore.set(token, { zipPath, outputDir, filename: `${title}.zip` });
|
||||
send('zip-ready', { token, filename: `${title}.zip` });
|
||||
send('done', 'ZIP ready for download.');
|
||||
} else {
|
||||
send('done', 'Download complete!');
|
||||
}
|
||||
}
|
||||
|
||||
function createZip(sourceDir, zipPath, archiveDirName, send, signal) {
|
||||
const script = join(process.cwd(), 'scripts', 'create_zip.py');
|
||||
const args = [script, sourceDir, zipPath, archiveDirName];
|
||||
if (config.zipExtraDir) args.push(config.zipExtraDir);
|
||||
return runProcess('python3', args, line => send('log', line), signal);
|
||||
}
|
||||
|
||||
function runProcess(cmd, args, onLine, signal, spawnOptions = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args, { signal, ...spawnOptions });
|
||||
let settled = false;
|
||||
const settle = (fn, val) => { if (!settled) { settled = true; fn(val); } };
|
||||
|
||||
const handleData = data =>
|
||||
data.toString().split('\n').filter(Boolean).forEach(onLine);
|
||||
|
||||
proc.stdout.on('data', handleData);
|
||||
proc.stderr.on('data', handleData);
|
||||
proc.on('error', err => {
|
||||
if (err.name === 'AbortError') settle(resolve, undefined);
|
||||
else settle(reject, err);
|
||||
});
|
||||
proc.on('close', code => {
|
||||
if (code === 0 || code === null) settle(resolve, undefined);
|
||||
else settle(reject, new Error(`Process exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
43
src/routes/api/zip/+server.js
Normal file
43
src/routes/api/zip/+server.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { createReadStream, statSync } from 'fs';
|
||||
import { rm } from 'fs/promises';
|
||||
import { Readable } from 'stream';
|
||||
import { config, zipStore } from '$lib/server/store.js';
|
||||
|
||||
export async function GET({ url }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return new Response('Missing token', { status: 400 });
|
||||
|
||||
const entry = zipStore.get(token);
|
||||
if (!entry) return new Response('Not found or already downloaded', { status: 404 });
|
||||
|
||||
// Single-use token — remove immediately
|
||||
zipStore.delete(token);
|
||||
|
||||
let size;
|
||||
try {
|
||||
size = statSync(entry.zipPath).size;
|
||||
} catch {
|
||||
return new Response('ZIP file not found on server', { status: 404 });
|
||||
}
|
||||
|
||||
const nodeStream = createReadStream(entry.zipPath);
|
||||
|
||||
if (config.deleteAfterSend) {
|
||||
nodeStream.on('close', () => {
|
||||
setTimeout(async () => {
|
||||
await rm(entry.outputDir, { recursive: true, force: true }).catch(() => {});
|
||||
await rm(entry.zipPath, { force: true }).catch(() => {});
|
||||
}, config.deleteDelay);
|
||||
});
|
||||
}
|
||||
|
||||
const safeFilename = entry.filename.replace(/[^\w\s.\-()]/g, '_');
|
||||
|
||||
return new Response(Readable.toWeb(nodeStream), {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${safeFilename}"`,
|
||||
'Content-Length': String(size),
|
||||
}
|
||||
});
|
||||
}
|
||||
38
start.sh
Executable file
38
start.sh
Executable file
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# yt-dlf startup script
|
||||
#
|
||||
# Environment variables:
|
||||
# PORT Port to listen on (default: 3000)
|
||||
# ORIGIN Public URL of this server, e.g. http://myserver:3000 (required in production)
|
||||
# YTDLP_PATH Path to yt-dlp binary (default: yt-dlp, must be on PATH)
|
||||
# FFMPEG_PATH Path to ffmpeg binary (default: ffmpeg, must be on PATH)
|
||||
#
|
||||
# DOWNLOAD_DIR Directory where downloaded files are stored (default: ~/YouTube)
|
||||
#
|
||||
# ZIP_AND_SEND Set to "true" to ZIP the downloaded files and offer them as a
|
||||
# browser download instead of (only) saving them on the server.
|
||||
# A random prefix is added to the directory name to avoid collisions
|
||||
# when multiple users download the same video simultaneously.
|
||||
# (default: false)
|
||||
#
|
||||
# DELETE_AFTER_SEND When ZIP_AND_SEND=true: delete the files from the server after
|
||||
# the ZIP has been sent to the browser. (default: false)
|
||||
#
|
||||
# DELETE_DELAY Seconds to wait before deleting files after the ZIP has been sent.
|
||||
# Only relevant when DELETE_AFTER_SEND=true. (default: 0)
|
||||
#
|
||||
# ZIP_EXTRA_DIR Optional directory whose files are added to every ZIP alongside
|
||||
# the downloaded files. Subdirectory structure within ZIP_EXTRA_DIR
|
||||
# is preserved. Only used when ZIP_AND_SEND=true. (default: none)
|
||||
|
||||
export PORT="${PORT:-3000}"
|
||||
export ORIGIN="${ORIGIN:-http://localhost:${PORT}}"
|
||||
|
||||
# Uncomment and adjust the lines below to configure:
|
||||
# export DOWNLOAD_DIR="$HOME/YouTube"
|
||||
# export ZIP_AND_SEND="false"
|
||||
# export ZIP_EXTRA_DIR="/path/to/extra/files"
|
||||
# export DELETE_AFTER_SEND="false"
|
||||
# export DELETE_DELAY="0"
|
||||
|
||||
exec node build/index.js
|
||||
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
17
svelte.config.js
Normal file
17
svelte.config.js
Normal file
@ -0,0 +1,17 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
vite.config.js
Normal file
14
vite.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: parseInt(process.env.PORT ?? '5173'),
|
||||
strictPort: false
|
||||
},
|
||||
preview: {
|
||||
port: parseInt(process.env.PORT ?? '4173'),
|
||||
strictPort: false
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user