Metadata-Version: 2.4
Name: ytp-dl
Version: 2026.9.10
Summary: Privacy-focused media downloader API: yt-dlp + isolated Mullvad WireGuard + SSE streaming
Home-page: https://github.com/dumgum84/ytp-dl
Author: dumgum82
Author-email: dumgum42@gmail.com
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Utilities
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: yt-dlp[curl-cffi,default]
Requires-Dist: flask
Requires-Dist: requests
Requires-Dist: gunicorn
Requires-Dist: Pillow
Requires-Dist: mutagen
Requires-Dist: boto3
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# ytp-dl

[![PyPI version](https://img.shields.io/pypi/v/ytp-dl.svg)](https://pypi.org/project/ytp-dl/)
[![Python Support](https://img.shields.io/pypi/pyversions/ytp-dl.svg)](https://pypi.org/project/ytp-dl/)
[![License](https://img.shields.io/pypi/l/ytp-dl.svg)](https://pypi.org/project/ytp-dl/)
[![Downloads](https://img.shields.io/pypi/dm/ytp-dl.svg)](https://pypi.org/project/ytp-dl/)

Privacy-focused media downloader API for Linux VPS deployments — powered by yt-dlp, routed through Mullvad VPN, with real-time SSE log streaming.

---

## Features

- Privacy-first: yt-dlp runs inside an isolated Mullvad WireGuard network namespace; the VPS host itself never joins the VPN
- Smart quality selection: prefers 1080p H.264 + AAC (no transcoding)
- Best format mode: let yt-dlp pick the highest quality available (adaptive, no transcoding)
- Audio extraction: best audio stream as MP3 with cover art and title/artist/date tags embedded in the file (re-encodes only when the source isn't already MP3)
- Playlist support: YouTube playlists, SoundCloud sets, Bilibili series, Odysee playlists — downloads all tracks and produces a ZIP of the individual files
- Multi-URL support: pass several comma-separated URLs (single videos and/or playlists) in one request — each downloads into its own subdirectory to avoid filename collisions, and the whole set is returned as a single ZIP
- Optional real-time track info: with the `metadata` field on, the job emits per-file title/artist and a thumbnail *to the client* as it runs — `[meta]` / `[meta_thumb]` SSE events plus a sidecar image — to drive Media Session controls (opt-in, off by default)
- Streaming HTTP API:
  - `POST /api/download` streams real-time yt-dlp output as Server-Sent Events (SSE)
  - `GET  /api/fetch/<job_id>` fetches the finished file
  - `GET  /api/fetch/<job_id>/<filename>` fetches a specific file from a job by name
- Optional R2 upload: push completed files to Cloudflare R2; playlist/multi jobs upload each track individually so clients can stream them without unpacking the ZIP
- Stable host networking: SSH, the public API, and R2 uploads stay on the normal VPS route while only yt-dlp traffic uses Mullvad
- VPS-ready: automated installer script for Ubuntu

---

## Installation

```bash
pip install ytp-dl==2026.9.10
pip install --upgrade --pre "yt-dlp[default]"
```

### Requirements

- Linux (tested on Ubuntu 24.04/25.04)
- A Mullvad account (the installer generates WireGuard configs with Mullvad's official `wg-tools` generator)
- FFmpeg (audio/video handling)
- Deno (system-wide; required by yt-dlp for modern YouTube extraction)
- Python 3.10+

Notes:
- yt-dlp expects **Deno** to be available on `PATH` to run its JavaScript-based extraction logic.
- `yt-dlp` is explicitly upgraded with `--pre` so the latest nightly/pre-release is used instead of the stable channel.

---

## Quick start

A download is a **two-phase** flow:

1) Start the job and stream logs:
- `POST /api/download` (SSE)

2) Fetch the finished file:
- `GET /api/fetch/<job_id>`

### Start a best-quality download

```bash
curl -N --http1.1 \
  -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" \
  -X POST "http://YOUR_VPS_IP:5000/api/download" \
  --data-binary '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","extension":"best","job_id":"demo3"}'
```

### Start a video download (1080p MP4)

Choose a `job_id` you can reuse for the follow-up fetch (only letters, numbers, `-`, `_`).

```bash
curl -N --http1.1 \
  -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" \
  -X POST "http://YOUR_VPS_IP:5000/api/download" \
  --data-binary '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","extension":"mp4","resolution":1080,"job_id":"demo1"}'
```

### Start an audio download (MP3)

```bash
curl -N --http1.1 \
  -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" \
  -X POST "http://YOUR_VPS_IP:5000/api/download" \
  --data-binary '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","extension":"mp3","job_id":"demo2"}'
```


### Fetch the finished file

When the SSE stream emits a line like:

```
data: [fetch] /api/fetch/demo1
```

Fetch the file using the same `job_id`:

```bash
curl -L -O -J "http://YOUR_VPS_IP:5000/api/fetch/demo1"
```

- `-O -J` tells curl to use the filename from `Content-Disposition`.

### Windows (CMD.exe) examples

Start the SSE stream:

```bat
curl -N --http1.1 ^
  -H "Accept: text/event-stream" ^
  -H "Content-Type: application/json" ^
  -X POST "http://YOUR_VPS_IP:5000/api/download" ^
  --data-binary "{\"url\":\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\",\"extension\":\"mp4\",\"resolution\":1080,\"job_id\":\"demo1\"}"
```

Note: PowerShell handles JSON escaping differently — wrap the `--data-binary` value in single quotes and use standard double quotes inside.

Fetch the finished file:

```bat
curl -L -O -J "http://YOUR_VPS_IP:5000/api/fetch/demo1"
```

---

## Python client

A minimal Python script that replicates the two-phase download flow (SSE stream → fetch) and handles server-side errors with retries and playlist resume.

### Usage

```bash
# Single video — MP4
python3 ytp-dl.py --base "http://YOUR_VPS_IP:5000" --url "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --extension mp4 --resolution 1080 --out-dir .

# Audio — MP3
python3 ytp-dl.py --base "http://YOUR_VPS_IP:5000" --url "https://soundcloud.com/artist/track" --extension mp3 --out-dir .

# Playlist with retries
python3 ytp-dl.py --base "http://YOUR_VPS_IP:5000" --url "https://www.youtube.com/playlist?list=PLxxx" --extension mp4 --out-dir ./downloads
```

Set `YTPDL_BASE` in your environment to avoid passing `--base` every time:

```bash
export YTPDL_BASE="http://YOUR_VPS_IP:5000"
python3 ytp-dl.py --url "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
```

### Arguments

| Argument | Default | Description |
|---|---|---|
| `--base` | `$YTPDL_BASE` or `http://127.0.0.1:5000` | VPS base URL |
| `--url` | *(required)* | Media URL to download |
| `--extension` | `mp4` | `mp4`, `mp3`, or `best` |
| `--resolution` | `1080` | Max height cap (ignored for `mp3`) |
| `--metadata` | *(off)* | Emit per-file title/artist + thumbnail to the client (Media Session controls) |
| `--out-dir` | `.` | Directory to save the file |
| `--token` | `$YTPDL_VPS_API_TOKEN` or *(empty)* | Sent as the `X-YTPDL-Token` header; only needed if the server sets `YTPDL_VPS_API_TOKEN` |
| `--max-retries` | `5` | Retries on server-side error, e.g. rate-limit |
| `--retry-delay` | `1` | Seconds before first retry; doubles each attempt (max 60s) |
| `--retry-factor` | `2` | Backoff multiplier applied after each retry |
| `--connect-timeout` | `15` | Connection timeout in seconds |
| `--read-timeout` | `300` | Read timeout in seconds |

### Retry behavior

On any server-side error (rate-limit, VPN cycle, etc.), the script retries automatically up to `--max-retries` times with exponential backoff (`--retry-delay` × `--retry-factor` each attempt, capped at 60s). It reuses the same `job_id` on each attempt — for playlist downloads, the VPS `.ytdlp-archive` skips already-completed tracks so nothing is re-downloaded.

### Installation

```bash
pip install requests
```

Save the script as `ytp-dl.py`, then run it with the usage examples above.

### Script

```python
#!/usr/bin/env python3
"""
ytp-dl Python client (SSE + fetch)

Flow:
  1) POST /api/download         -> streams yt-dlp logs as Server-Sent Events (SSE)
  2) GET  /api/fetch/<job_id>   -> downloads the finished file

On rate-limit ([error] from server), re-POSTs with the same job_id so the
VPS .ytdlp-archive skips already-downloaded tracks (playlist resume).

Requirements:
  pip install requests
"""

from __future__ import annotations

import argparse
import os
import re
import sys
import time
import uuid
from dataclasses import dataclass
from typing import Optional
from urllib.parse import unquote

import requests


_FETCH_RX    = re.compile(r"(/api/fetch/[A-Za-z0-9_\-]+)")
_START_JOB_RX = re.compile(r"\[start\]\s+job_id=([A-Za-z0-9_\-]+)")

# Lines that are internal protocol signals — suppress from printed output.
_SUPPRESS_PREFIXES = (
    "[playlist_count] ",
    "[playlist_title] ",
    "[r2_track] ",
    "[r2_tracks_incomplete]",
    "[meta] ",
    "[meta_thumb] ",
    "[ready] ",
    "[file] ",
)


@dataclass(frozen=True)
class Config:
    base: str
    url: str
    extension: str
    resolution: Optional[int]
    job_id: str
    metadata: bool
    token: str
    out_dir: str
    connect_timeout_s: float
    read_timeout_s: float
    max_retries: int
    retry_delay_s: float
    retry_factor: float


def _normalize_base(base: str) -> str:
    base = (base or "").strip().rstrip("/")
    if not base:
        raise ValueError("Missing --base")
    return base


def _parse_fetch_path(msg: str) -> Optional[str]:
    m = _FETCH_RX.search(msg or "")
    return m.group(1) if m else None


def _parse_job_id(msg: str) -> Optional[str]:
    m = _START_JOB_RX.search(msg or "")
    return m.group(1) if m else None


def _filename_from_content_disposition(cd: str) -> Optional[str]:
    cd = (cd or "").strip()
    if not cd:
        return None

    # filename*
    m = re.search(r"filename\*\s*=\s*([^;]+)", cd, flags=re.I)
    if m:
        v = m.group(1).strip().strip('"')
        _, _, tail = v.partition("''")
        raw = tail or v
        try:
            name = unquote(raw)
            return os.path.basename(name)
        except Exception:
            pass

    # filename
    m = re.search(r'filename\s*=\s*("?)([^";]+)\1', cd, flags=re.I)
    if m:
        return os.path.basename(m.group(2).strip())

    return None


def _safe_default_filename(ext: str) -> str:
    ext = (ext or "").lower().strip().lstrip(".")
    if ext in {"mp3", "mp4"}:
        return f"download.{ext}"
    return "download.bin"


def _auto_job_id() -> str:
    return f"job_{uuid.uuid4().hex[:12]}"


def stream_logs_and_get_fetch_path(
    cfg: Config,
) -> tuple[Optional[str], str, Optional[str]]:
    """
    Stream SSE logs from the VPS download endpoint.

    Returns (fetch_path, resolved_job_id, error_msg).
    error_msg is non-None when the server emitted [error].
    For playlists the fetched file is the ZIP of individual tracks.
    """
    payload: dict = {"url": cfg.url, "extension": cfg.extension, "job_id": cfg.job_id}
    if cfg.resolution is not None:
        payload["resolution"] = int(cfg.resolution)
    if cfg.metadata:
        payload["metadata"] = True

    headers = {"Accept": "text/event-stream", "Content-Type": "application/json"}
    if cfg.token:
        headers["X-YTPDL-Token"] = cfg.token

    fetch_path: Optional[str] = None
    resolved_job_id: str = cfg.job_id
    error_msg: Optional[str] = None

    with requests.post(
        f"{cfg.base}/api/download",
        json=payload,
        stream=True,
        headers=headers,
        timeout=(cfg.connect_timeout_s, cfg.read_timeout_s),
    ) as r:
        r.raise_for_status()

        for raw in r.iter_lines(decode_unicode=True):
            if raw is None:
                continue
            line = (raw or "").strip("\r")
            if not line or line.startswith(":"):
                continue
            if not line.startswith("data:"):
                continue

            msg = line[5:].lstrip()
            if not msg:
                continue

            # Capture job id from [start] event.
            jid = _parse_job_id(msg)
            if jid:
                resolved_job_id = jid

            # Capture fetch hint.
            if not fetch_path:
                fp = _parse_fetch_path(msg)
                if fp:
                    fetch_path = fp

            # Detect server-side error.
            if msg.startswith("[error]"):
                error_msg = msg[len("[error]"):].strip()
                print(msg, flush=True)
                continue

            # Suppress internal protocol events from output.
            if any(msg.startswith(p) for p in _SUPPRESS_PREFIXES):
                continue

            # Suppress [done] — protocol signal only.
            if msg.startswith("[done]") or msg == "All downloads complete.":
                continue

            print(msg, flush=True)

    return fetch_path, resolved_job_id, error_msg


def fetch_file(cfg: Config, fetch_path: str) -> str:
    os.makedirs(cfg.out_dir, exist_ok=True)

    headers = {"Connection": "close"}
    if cfg.token:
        headers["X-YTPDL-Token"] = cfg.token

    with requests.get(
        f"{cfg.base}{fetch_path}",
        stream=True,
        timeout=(cfg.connect_timeout_s, cfg.read_timeout_s),
        headers=headers,
    ) as r:
        r.raise_for_status()

        cd = r.headers.get("Content-Disposition", "")
        filename = _filename_from_content_disposition(cd) or _safe_default_filename(cfg.extension)
        out_path = os.path.abspath(os.path.join(cfg.out_dir, filename))

        if os.path.exists(out_path):
            base, ext = os.path.splitext(out_path)
            i = 1
            while os.path.exists(f"{base}_{i}{ext}"):
                i += 1
            out_path = f"{base}_{i}{ext}"

        with open(out_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=1024 * 128):
                if chunk:
                    f.write(chunk)

    return out_path


def parse_args(argv: list[str]) -> Config:
    p = argparse.ArgumentParser(description="ytp-dl client (SSE + fetch)")
    p.add_argument(
        "--base",
        default=os.environ.get("YTPDL_BASE", "http://127.0.0.1:5000"),
        help="Base URL, e.g. http://YOUR_VPS_IP:5000 (env: YTPDL_BASE)",
    )
    p.add_argument("--url", required=True, help="Media URL to download")
    p.add_argument("--extension", default="mp4", choices=["mp4", "mp3", "best"],
                   help="Download mode")
    p.add_argument("--resolution", type=int, default=1080,
                   help="Max height cap (default: 1080)")
    p.add_argument("--metadata", action="store_true",
                   help="Emit per-file track info to the client (sidecar thumbnail + title/artist)")
    p.add_argument("--out-dir", default=".", help="Directory to save the fetched file")
    p.add_argument(
        "--token",
        default=os.environ.get("YTPDL_VPS_API_TOKEN", ""),
        help="Shared secret sent as the X-YTPDL-Token header (env: YTPDL_VPS_API_TOKEN). "
             "Only required if the server has YTPDL_VPS_API_TOKEN set; leave empty otherwise.",
    )
    p.add_argument("--connect-timeout", type=float, default=15.0)
    p.add_argument("--read-timeout", type=float, default=300.0)
    p.add_argument("--max-retries", type=int, default=5,
                   help="Max retries on server-side error, e.g. rate-limit (default: 5)")
    p.add_argument("--retry-delay", type=float, default=1.0,
                   help="Seconds before first retry; doubles each attempt (default: 1)")
    p.add_argument("--retry-factor", type=float, default=2.0,
                   help="Backoff multiplier applied after each retry (default: 2)")

    a = p.parse_args(argv)

    return Config(
        base=_normalize_base(a.base),
        url=a.url,
        extension=a.extension,
        resolution=a.resolution if a.extension != "mp3" else None,
        job_id=_auto_job_id(),
        metadata=a.metadata,
        token=a.token,
        out_dir=a.out_dir,
        connect_timeout_s=a.connect_timeout,
        read_timeout_s=a.read_timeout,
        max_retries=a.max_retries,
        retry_delay_s=a.retry_delay,
        retry_factor=a.retry_factor,
    )


def main(argv: list[str]) -> int:
    cfg = parse_args(argv)

    attempt = 0
    delay   = cfg.retry_delay_s

    while True:
        attempt += 1

        try:
            fetch_path, resolved_job_id, error_msg = stream_logs_and_get_fetch_path(cfg)
        except requests.RequestException as e:
            print(f"ERROR: Request failed: {e}", file=sys.stderr)
            return 1

        # Success path — no server error.
        if error_msg is None:
            break

        # Server signalled an error (rate-limit, VPN cycle needed, etc.)
        if attempt >= cfg.max_retries:
            print(
                f"ERROR: Server error after {attempt} attempt(s): {error_msg}",
                file=sys.stderr,
            )
            return 1

        print(
            f"[retry] Attempt {attempt}/{cfg.max_retries} failed: {error_msg}",
            file=sys.stderr,
        )
        print(f"[retry] Retrying in {delay:.0f}s...", file=sys.stderr)
        time.sleep(delay)
        delay = min(delay * cfg.retry_factor, 60.0)   # exponential backoff, cap at 60s

        # Keep the same job_id so the VPS .ytdlp-archive resumes the playlist.

    if not fetch_path:
        fetch_path = f"/api/fetch/{resolved_job_id}"

    try:
        out = fetch_file(cfg, fetch_path)
    except requests.RequestException as e:
        print(f"ERROR: Fetch failed: {e}", file=sys.stderr)
        return 1

    print(f"Saved: {out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
```

---

## Configuration

Runtime config lives in `/etc/default/ytp-dl-api` (the installer creates it). Edit the file and restart the service to apply changes.

### Installer-only variables

| Variable | Description | Default |
|---|---|---:|
| `PORT` | API server port | `5000` |
| `APP_DIR` | Installation directory | `/opt/yt-dlp-mullvad` |
| `YTPDL_VERSION` | ytp-dl package version installed from PyPI | `2026.9.10` |
| `MV_ACCOUNT` | Mullvad account number used to create/reuse the WireGuard device | *(required)* |
| `YTPDL_MULLVAD_LOCATION` | Relay hostname prefix used when generating active WireGuard configs | `us` |
| `VPN_NAMESPACE` | Linux network namespace used for yt-dlp | `ytpdl-vpn` |
| `VPN_MTU` | WireGuard interface MTU | `1420` |
| `VPN_ROTATE_COOLDOWN` | Minimum seconds between concurrent-triggered relay rotations | `10` |

### Runtime variables

These are read from `/etc/default/ytp-dl-api`. You can also export any of them before running the installer to pre-seed that file.

| Variable | Description | Default |
|---|---|---:|
| `YTPDL_VENV` | Path to virtualenv for ytp-dl | `/opt/yt-dlp-mullvad/venv` |
| `YTPDL_VPN_HELPER` | Helper used to ensure/rotate/execute inside the isolated VPN namespace | `/usr/local/sbin/ytpdl-vpn` |
| `YTPDL_SLOT_DIR` | Directory containing cross-process concurrency slot locks | `/run/ytpdl-slots` |
| `YTPDL_MAX_CONCURRENT` | VPS-wide maximum concurrent download jobs across all Gunicorn workers | `8` |
| `YTPDL_DONE_TTL_S` | Seconds to keep a completed job dir before deletion | `300` |
| `YTPDL_STALE_JOB_TTL_S` | Seconds before an unfinished/unfetched job dir is force-deleted | `3600` |
| `YTPDL_MIN_FREE_DISK_MB` | Minimum free disk MB — new jobs refused and emergency cleanup triggered below this threshold | `500` |
| `YTPDL_CLEANUP_INTERVAL_S` | How often the background cleanup thread runs in seconds | `60` |
| `YTPDL_JOB_TIMEOUT_S` | Hard kill timeout for a single-file yt-dlp process | `1800` |
| `YTPDL_PLAYLIST_JOB_TIMEOUT_S` | Hard kill timeout for a playlist yt-dlp process | `21600` |
| `GUNICORN_WORKERS` | Gunicorn worker processes | `4` |
| `GUNICORN_THREADS` | Threads per Gunicorn worker | `8` |
| `YTPDL_R2_UPLOAD` | Upload completed files to R2 | `0` |
| `R2_ENDPOINT` | R2 endpoint (no bucket suffix) | *(empty)* |
| `R2_BUCKET` | R2 bucket name | *(empty)* |
| `R2_ACCESS_KEY_ID` | R2 uploader access key id | *(empty)* |
| `R2_SECRET_ACCESS_KEY` | R2 uploader secret access key | *(empty)* |
| `AWS_EC2_METADATA_DISABLED` | Disable EC2 metadata fetch | `true` |
| `YTPDL_VPS_API_TOKEN` | Shared secret to authenticate requests to this API. Must be set to the same value in client-side env. Leave empty to disable auth. | *(empty)* |

To change runtime configuration:

```bash
sudo nano /etc/default/ytp-dl-api
sudo systemctl restart ytp-dl-api
```

Keep secrets (e.g. `R2_SECRET_ACCESS_KEY`) on the server only — do not commit them to repos or READMEs.

---

## Managing your VPS service

```bash
sudo systemctl status ytp-dl-api
sudo journalctl -u ytp-dl-api -f
sudo systemctl restart ytp-dl-api
sudo systemctl stop ytp-dl-api
sudo systemctl start ytp-dl-api
```

---

## API reference

### `POST /api/download` (SSE logs)

Request body:

```json
{
  "url": "string (required)",
  "resolution": "integer (optional, default: 1080)",
  "extension": "string (optional: 'mp4', 'mp3', or 'best')",
  "job_id": "string (optional, recommended for fetch; [A-Za-z0-9_-])",
  "metadata": "boolean (optional, default: false)"
}
```

- `mp4` — 1080p H.264 + AAC, no transcoding
- `mp3` — best audio stream, output as MP3 with cover art and title/artist tags embedded in the file
- `best` — yt-dlp selects the highest quality adaptive format; `resolution` is ignored
- `metadata` — when `true`, the job emits per-file `[meta]` (title/artist) and `[meta_thumb]` (artwork) events to the client as it runs, plus a sidecar thumbnail, to drive Media Session controls. Defaults to `false`.

Response — `200 OK` SSE stream (`text/event-stream`):

```
data: [start] job_id=<job_id>
data: [total_items] <n>               # playlist/multi only — track count
data: <yt-dlp output lines>
data: [meta] media=<filename>\ttitle=<title>\tartist=<artist>   # metadata=true only — per file
data: [r2_upload] XX.XX%              # R2 only — per track, then the result file
data: [r2_track] key=<object_key>     # R2 + playlist/multi only — one per uploaded track
data: [meta_thumb] media=<filename>\tkey=<object_key>    # metadata=true + R2 — artwork in R2
data: [meta_thumb] media=<filename>\tfile=<thumb_filename> # metadata=true, no R2 — fetch via /api/fetch
data: [r2_tracks_incomplete]          # R2 + playlist/multi only — a track upload failed
data: [ready] job_id=<job_id>
data: [file] <filename>               # the media file — or the ZIP name for playlists
data: [r2] key=<object_key>           # R2 only — the result file's key
data: [fetch] /api/fetch/<job_id>
data: [done]
```

Other responses:
- `400 Bad Request` — missing or invalid URL/params
- `503 Service Unavailable` — server busy (max concurrent downloads reached)

### `GET /api/fetch/<job_id>`

Returns the finished file as an attachment. The job directory is cleaned up after the response completes (or after `YTPDL_DONE_TTL_S` elapses).

### `GET /api/fetch/<job_id>/<filename>`

Returns a specific file from the job directory by name. Useful for fetching any secondary output files produced by a job (e.g. the ZIP of individual playlist tracks).

### `GET /healthz`

```json
{
  "ok": true,
  "in_use": 1,
  "capacity": 8
}
```

---

## VPS deployment

Version **2026.9.10** replaces the old whole-host Mullvad design with an isolated WireGuard network namespace.

### Network architecture

```text
VPS host namespace
├── SSH :22                 -> normal VPS network
├── Gunicorn/API :5000      -> normal VPS network
├── Cloudflare R2 uploads   -> normal VPS network
└── WireGuard UDP socket    -> normal VPS network
                              │
                              ▼
                    ytpdl-vpn namespace
                    ├── lo
                    └── ytpdlwg
                         │
                         └── yt-dlp + ffmpeg/Deno -> Mullvad -> Internet
```

The WireGuard interface is created in the host namespace and then moved into `ytpdl-vpn`. Linux WireGuard keeps the encrypted UDP socket in the interface's birth namespace, so the tunnel can use the VPS's normal physical route while processes inside `ytpdl-vpn` see only `lo` and the WireGuard interface. If the VPN is down, yt-dlp has no fallback interface and fails closed; SSH/API/R2 are unaffected.

This also removes the old `ytpdl-public` policy-routing table and Mullvad nftables port exceptions. Relay connect/rotation is serialized with a host-level `flock`, so multiple Gunicorn workers cannot race while reconfiguring the shared tunnel.

### Global concurrency

`YTPDL_MAX_CONCURRENT` is now enforced across **all** Gunicorn processes with kernel `flock()` slot files. With the installer defaults:

```text
YTPDL_MAX_CONCURRENT=8
GUNICORN_WORKERS=4
GUNICORN_THREADS=8
```

the VPS can run at most **8 download jobs total**, not 8 per worker. `/healthz` reports the VPS-wide in-use count.

### Install

The repository includes `VPS_Installation.sh`. It is intended for a fresh Ubuntu VPS, but it also removes the old ytp-dl policy-routing/nftables services if they are present. The installer:

1. Stops the old global Mullvad path and removes the legacy ytp-dl routing exceptions.
2. Installs WireGuard, ffmpeg, Python, Deno, and supporting packages.
3. Uses Mullvad's official `wg-tools` generator to create active WireGuard configs for the selected location prefix.
4. Installs `/usr/local/sbin/ytpdl-vpn`, which owns namespace connect/rotate/status/exec operations.
5. Installs ytp-dl `2026.9.10` plus the latest pre-release `yt-dlp[default,curl-cffi]`.
6. Starts the Gunicorn API with a 6 GB systemd memory cap.
7. Verifies both `/healthz` and the isolated Mullvad tunnel.

Run it as root:

```bash
sudo -s
chmod +x VPS_Installation.sh
./VPS_Installation.sh
```

Useful checks after installation:

```bash
systemctl status ytp-dl-api.service --no-pager
journalctl -u ytp-dl-api.service -f
/usr/local/sbin/ytpdl-vpn status
/usr/local/sbin/ytpdl-vpn ip
/usr/local/sbin/ytpdl-vpn rotate
ip netns exec ytpdl-vpn ip addr
curl http://127.0.0.1:5000/healthz
```

If the Mullvad config generator reports that no new device can be created, remove an unused device from the Mullvad account and rerun the installer. The installer persists `/etc/ytpdl-vpn/device.conf`, so normal reruns reuse the same WireGuard device instead of creating a new one.
