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:
Stefan Waidele 2026-05-16 20:33:23 +02:00
commit 3544a89609
23 changed files with 3629 additions and 0 deletions

26
.gitignore vendored Normal file
View 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-*

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

87
INSTALL.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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 &amp; 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>

View 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}`));
});
});
}

View 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
View 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
View File

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

17
svelte.config.js Normal file
View 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
View 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
}
});