Metadata-Version: 2.4
Name: ytp-dl
Version: 2026.9.11.1
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: playlist/multi jobs keep completed media local while yt-dlp runs; after downloading finishes, the API uploads the individual tracks to Cloudflare R2 and streams the final ZIP directly from those local files into an R2 multipart upload — no R2 work is interleaved between downloads, no second full-size local ZIP is created, and no R2 read-back pass is needed
- Disk-safe jobs: cross-process active-job locks prevent stale/emergency cleanup from deleting live downloads, and new jobs are refused below a configurable free-space reserve
- 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 --pre "ytp-dl==2026.9.11.1"
```

### 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[default,curl-cffi]` is installed automatically as a `ytp-dl` dependency; `--pre` allows pip to select the latest available yt-dlp pre-release/nightly on a fresh install.

---

## 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.11.1` |
| `YTPDL_MEMORY_MAX` | systemd memory limit for the complete ytp-dl API service | `6G` |
| `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_ACTIVE_LOCK_DIR` | Cross-process locks identifying job directories that are actively in use | `/run/ytpdl-active` |
| `YTPDL_CLEANUP_LOCK_PATH` | Global cleanup lock so only one Gunicorn worker mutates job directories at a time | `/run/lock/ytpdl-cleanup.lock` |
| `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` | Free-space reserve in MB — new jobs are refused and inactive-job cleanup is attempted below this threshold | `8192` |
| `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` |
| `YTPDL_PLAYLIST_PASSES` | Maximum playlist passes used to retry missing entries while reusing the yt-dlp download archive | `5` |
| `YTPDL_R2_ZIP_PART_SIZE_MB` | Multipart part/buffer size used while streaming final collection ZIPs directly to R2 | `16` |
| `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: [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
- `409 Conflict` — the same `job_id` is already active
- `503 Service Unavailable` — server busy (max concurrent downloads reached)
- `507 Insufficient Storage` — the VPS is below its configured free-space reserve

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

Returns the finished file as an attachment. Local-only results are served from the job directory. R2-backed results may already have had their local payload bytes purged; in that case the endpoint redirects to a short-lived signed R2 URL using the saved result metadata.

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

Returns a specific playlist/multi track for inline streaming. Local-only jobs serve the file directly. R2-backed collection tracks are looked up in the small persisted collection manifest and redirected to a signed R2 URL after their local copies have been deleted.

### `GET /healthz`

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

---

## VPS deployment

Version **2026.9.11.1** uses an isolated WireGuard network namespace for yt-dlp traffic, VPS-wide concurrency controls, active-job protection, and disk-safe R2 handling. Playlist and multi-URL media stays local while yt-dlp is downloading, and R2 processing begins only after the collection download has finished.

### 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 moved into `ytpdl-vpn`. Its encrypted UDP socket remains in the host namespace, while yt-dlp and child processes see only loopback plus the WireGuard interface. If the VPN is unavailable, yt-dlp has no ordinary-network fallback; SSH, the public API, systemd and R2 uploads remain on the VPS's normal route.

Relay connect and rotation are serialized with a host-level `flock`, so multiple Gunicorn workers cannot race while reconfiguring the shared isolated tunnel. Host services remain on the VPS's normal network while yt-dlp and its child processes are confined to the Mullvad namespace.

### Global concurrency

`YTPDL_MAX_CONCURRENT` is 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 therefore run at most **8 download jobs total**, not 8 per worker. `/healthz` reports the VPS-wide in-use count.

### Playlist and multi-URL storage

With R2 enabled, playlists and multi-URL jobs download to the VPS first. Nothing is uploaded or deleted from inside yt-dlp's stdout/download loop, so yt-dlp is not paused for R2 work between completed items. When the collection finishes, the API uploads the retained local media files (and optional thumbnails) to R2, verifies them, and records their object keys in `.playlist-state.json`.

The final downloadable ZIP is then built **directly from those local media files into R2**. Python's standard-library `zipfile` writes an uncompressed ZIP stream into a small multipart buffer (default `16` MiB), and each part is uploaded to R2 as it fills. No full ZIP is written to VPS disk, and unlike 2026.9.11 the source tracks are not downloaded back from R2 to construct the ZIP. A 38 GB collection therefore needs roughly the 38 GB of source media plus the small multipart buffer, rather than roughly 38 GB of media + a second 38 GB local ZIP.

Only after the individual R2 objects and final ZIP have been successfully verified are the large local payload bytes purged. If R2 is disabled, the original local-ZIP behavior is preserved.

Active job directories hold separate cross-process locks, so stale/emergency cleanup skips live jobs. Cleanup itself is also serialized across Gunicorn workers. The default free-disk reserve is `8192` MB. Completed R2-backed payload bytes are purged immediately after successful post-download R2 processing while the tiny result/collection metadata remains available for the normal `YTPDL_DONE_TTL_S` fetch window.

### VPS installer

The complete Ubuntu installer is included below. Credentials are intentionally left as environment variables/placeholders for safe use in a public repository. Set them before running if you want R2 upload or API authentication enabled.

```bash
#!/usr/bin/env bash
# VPS_Installation.sh - Ubuntu VPS setup for ytp-dl 2026.9.11.1
#
# Architecture:
#   - SSH, Gunicorn/API and Cloudflare R2 stay on the VPS's normal network.
#   - ONLY yt-dlp (and children such as ffmpeg/Deno) run inside a dedicated
#     Linux network namespace whose sole non-loopback interface is WireGuard.
#   - The WireGuard interface is created in the host namespace, then moved into
#     the yt-dlp namespace. Its encrypted UDP socket remains in the host
#     namespace, while cleartext yt-dlp traffic can only leave through wg0.
#   - No global Mullvad route changes, no SSH/API nftables exceptions and no
#     custom source-policy routing are required.
#   - VPN rotation is serialized across all Gunicorn workers with flock().
#   - YTPDL_MAX_CONCURRENT is enforced globally by api.py across all workers.
#
# Target: Ubuntu 24.04/25.04 (also expected to work on newer Ubuntu releases).

set -euo pipefail

PORT="${PORT:-5000}"
APP_DIR="${APP_DIR:-/opt/yt-dlp-mullvad}"
VENV_DIR="${VENV_DIR:-${APP_DIR}/venv}"
YTPDL_VERSION="${YTPDL_VERSION:-2026.9.11.1}"

# Mullvad / isolated WireGuard namespace
MV_ACCOUNT="${MV_ACCOUNT:-}"
YTPDL_MULLVAD_LOCATION="${YTPDL_MULLVAD_LOCATION:-us}"
VPN_NAMESPACE="${VPN_NAMESPACE:-ytpdl-vpn}"
VPN_DIR="${VPN_DIR:-/etc/ytpdl-vpn}"
VPN_CONFIG_DIR="${VPN_CONFIG_DIR:-${VPN_DIR}/configs}"
VPN_HELPER="${VPN_HELPER:-/usr/local/sbin/ytpdl-vpn}"
VPN_MTU="${VPN_MTU:-1420}"
VPN_ROTATE_COOLDOWN="${VPN_ROTATE_COOLDOWN:-10}"

# API / Gunicorn
YTPDL_MAX_CONCURRENT="${YTPDL_MAX_CONCURRENT:-8}"
YTPDL_MIN_FREE_DISK_MB="${YTPDL_MIN_FREE_DISK_MB:-8192}"
YTPDL_R2_ZIP_PART_SIZE_MB="${YTPDL_R2_ZIP_PART_SIZE_MB:-16}"
YTPDL_MEMORY_MAX="${YTPDL_MEMORY_MAX:-6G}"
GUNICORN_WORKERS="${GUNICORN_WORKERS:-4}"
GUNICORN_THREADS="${GUNICORN_THREADS:-8}"

# R2
YTPDL_R2_UPLOAD="${YTPDL_R2_UPLOAD:-0}"
R2_ENDPOINT="${R2_ENDPOINT:-}"
R2_BUCKET="${R2_BUCKET:-}"
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-}"
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-}"
YTPDL_VPS_API_TOKEN="${YTPDL_VPS_API_TOKEN:-}"
export AWS_EC2_METADATA_DISABLED="true"

[[ "${EUID}" -eq 0 ]] || { echo "Please run as root (sudo -s)" >&2; exit 1; }
[[ -n "${MV_ACCOUNT}" ]] || { echo "MV_ACCOUNT is required" >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive

log() { printf '\n==> %s\n' "$*"; }

log "0) Prepare host networking and stop the API"
systemctl stop ytp-dl-api.service 2>/dev/null || true

# Ensure the host itself is not routed through the Mullvad app.
# yt-dlp uses the isolated WireGuard namespace configured below.
if command -v mullvad >/dev/null 2>&1; then
    mullvad disconnect >/dev/null 2>&1 || true
fi
systemctl disable --now mullvad-daemon.service 2>/dev/null || true
systemctl disable --now mullvad-early-boot-blocking.service 2>/dev/null || true

# Remove conflicting host-level routing or firewall rules if present.
systemctl disable --now ytpdl-policy-routing.service 2>/dev/null || true
systemctl disable --now ytpdl-mullvad-exclude-ports.service 2>/dev/null || true
rm -f /etc/systemd/system/ytpdl-policy-routing.service
rm -f /etc/systemd/system/ytpdl-mullvad-exclude-ports.service
rm -f /usr/local/sbin/ytpdl-policy-routing.sh
rm -f /usr/local/sbin/ytpdl-mullvad-exclusions.sh
rm -f /etc/ytpdl-mullvad-exclude-ports.nft
rm -f /etc/default/ytpdl-policy-routing
rm -f /etc/sysctl.d/99-ytpdl-policy-routing.conf
nft delete table inet ytpdl_mullvad_exclusions 2>/dev/null || true
while ip rule show | grep -qE '^11000:'; do
    ip rule del priority 11000 2>/dev/null || break
done
if grep -qE '^[[:space:]]*100[[:space:]]+ytpdl-public[[:space:]]*$' /etc/iproute2/rt_tables 2>/dev/null; then
    sed -i '/^[[:space:]]*100[[:space:]]\+ytpdl-public[[:space:]]*$/d' /etc/iproute2/rt_tables
fi
systemctl daemon-reload

log "1) Install base packages"
apt-get update
apt-get install -yq --no-install-recommends \
    python3-venv python3-pip python3-cryptography \
    curl ca-certificates ffmpeg unzip \
    iproute2 wireguard-tools util-linux

# Verify kernel WireGuard support early.
modprobe wireguard

log "2) Generate Mullvad WireGuard configurations"
install -d -m 700 "${VPN_DIR}" "${VPN_CONFIG_DIR}"
install -d -m 755 /usr/local/lib/ytpdl

# Official Mullvad wg-tools generator. It creates/reuses one WireGuard device key
# and generates configs for active relays matching YTPDL_MULLVAD_LOCATION.
curl -fsSLo /usr/local/lib/ytpdl/wg-mullvad.py \
    https://raw.githubusercontent.com/mullvad/wg-tools/main/wg-mullvad.py
chmod 755 /usr/local/lib/ytpdl/wg-mullvad.py

# Keep the device key, but refresh relay configs so stale/inactive entries do not
# accumulate across installer reruns.
rm -f "${VPN_CONFIG_DIR}"/*.conf 2>/dev/null || true
python3 /usr/local/lib/ytpdl/wg-mullvad.py \
    --account "${MV_ACCOUNT}" \
    --filter "${YTPDL_MULLVAD_LOCATION}" \
    --active \
    --settings-file "${VPN_DIR}/device.conf" \
    --output-dir "${VPN_CONFIG_DIR}"
chmod 600 "${VPN_DIR}/device.conf" "${VPN_CONFIG_DIR}"/*.conf

if ! find "${VPN_CONFIG_DIR}" -maxdepth 1 -type f -name '*.conf' -print -quit | grep -q .; then
    echo "No Mullvad WireGuard configs were generated for '${YTPDL_MULLVAD_LOCATION}'." >&2
    exit 1
fi

log "3) Install isolated VPN namespace helper"
cat > /etc/default/ytpdl-vpn <<EOF2
YTPDL_VPN_NAMESPACE=${VPN_NAMESPACE}
YTPDL_VPN_CONFIG_DIR=${VPN_CONFIG_DIR}
YTPDL_VPN_MTU=${VPN_MTU}
YTPDL_VPN_ROTATE_COOLDOWN=${VPN_ROTATE_COOLDOWN}
EOF2
chmod 600 /etc/default/ytpdl-vpn

cat > "${VPN_HELPER}" <<'EOF2'
#!/usr/bin/env bash
set -euo pipefail

source /etc/default/ytpdl-vpn

NS="${YTPDL_VPN_NAMESPACE:-ytpdl-vpn}"
CONFIG_DIR="${YTPDL_VPN_CONFIG_DIR:-/etc/ytpdl-vpn/configs}"
WG_IF="ytpdlwg"
MTU="${YTPDL_VPN_MTU:-1420}"
ROTATE_COOLDOWN="${YTPDL_VPN_ROTATE_COOLDOWN:-10}"
RUN_DIR="/run/ytpdl-vpn"
LOCK_FILE="/run/lock/ytpdl-vpn.lock"
CURRENT_FILE="${RUN_DIR}/current-config"
LAST_ROTATE_FILE="${RUN_DIR}/last-rotate"
STRIPPED_FILE="${RUN_DIR}/wireguard-stripped.conf"
CHECK_URL="https://am.i.mullvad.net/connected"
IP_URL="https://am.i.mullvad.net/ip"

mkdir -p "${RUN_DIR}" /run/lock
chmod 700 "${RUN_DIR}"

die() { echo "ytpdl-vpn: $*" >&2; exit 1; }

namespace_exists() {
    ip netns list | awk '{print $1}' | grep -Fxq "${NS}"
}

setup_namespace() {
    if ! namespace_exists; then
        ip netns add "${NS}"
    fi
    ip -n "${NS}" link set lo up
    mkdir -p "/etc/netns/${NS}"
    cat > "/etc/netns/${NS}/resolv.conf" <<'DNS'
nameserver 10.64.0.1
DNS
    chmod 644 "/etc/netns/${NS}/resolv.conf"
}

wg_exists() {
    namespace_exists && ip -n "${NS}" link show "${WG_IF}" >/dev/null 2>&1
}

connected() {
    wg_exists || return 1
    ip netns exec "${NS}" curl -fsS --connect-timeout 4 --max-time 8 "${CHECK_URL}" 2>/dev/null \
        | grep -qi 'You are connected to Mullvad'
}

current_config() {
    if [[ -s "${CURRENT_FILE}" ]]; then
        cat "${CURRENT_FILE}"
    fi
}

list_configs_random() {
    find "${CONFIG_DIR}" -maxdepth 1 -type f -name '*.conf' -print | shuf
}

down_locked() {
    setup_namespace
    if wg_exists; then
        ip -n "${NS}" link del "${WG_IF}" || true
    fi
}

connect_config() {
    local cfg="$1"
    [[ -f "${cfg}" ]] || return 1

    setup_namespace
    down_locked

    # wg(8) accepts the WireGuard fields only; wg-quick strip removes Address,
    # DNS and other wg-quick-only keys from the Mullvad-generated config.
    wg-quick strip "${cfg}" > "${STRIPPED_FILE}"
    chmod 600 "${STRIPPED_FILE}"

    # Critical namespace design: create the WireGuard interface on the HOST,
    # then move it into the restricted namespace. WireGuard keeps its encrypted
    # UDP socket in the namespace where it was born (the host), so the tunnel can
    # use the VPS's normal ens3 route while cleartext processes in ${NS} see only
    # lo + ${WG_IF}. There is therefore no non-VPN fallback path to leak through.
    ip link del "${WG_IF}" 2>/dev/null || true
    ip link add dev "${WG_IF}" type wireguard
    ip link set "${WG_IF}" netns "${NS}"

    ip netns exec "${NS}" wg setconf "${WG_IF}" "${STRIPPED_FILE}"

    local address_line
    address_line="$(sed -n 's/^[[:space:]]*Address[[:space:]]*=[[:space:]]*//Ip' "${cfg}" | head -n1)"
    [[ -n "${address_line}" ]] || { ip -n "${NS}" link del "${WG_IF}" || true; return 1; }

    local addr
    IFS=',' read -r -a _addrs <<< "${address_line}"
    for addr in "${_addrs[@]}"; do
        addr="${addr//[[:space:]]/}"
        [[ -n "${addr}" ]] || continue
        ip -n "${NS}" address add "${addr}" dev "${WG_IF}"
    done

    ip -n "${NS}" link set dev "${WG_IF}" mtu "${MTU}"
    ip -n "${NS}" link set dev "${WG_IF}" up
    ip -n "${NS}" route replace default dev "${WG_IF}"
    if printf '%s\n' "${address_line}" | grep -q ':'; then
        ip -n "${NS}" -6 route replace default dev "${WG_IF}" 2>/dev/null || true
    fi

    # Force a handshake and verify that the namespace really exits through Mullvad.
    local i
    for i in $(seq 1 15); do
        if connected; then
            printf '%s\n' "${cfg}" > "${CURRENT_FILE}"
            chmod 600 "${CURRENT_FILE}"
            echo "Connected: $(basename "${cfg}")"
            return 0
        fi
        sleep 1
    done

    ip -n "${NS}" link del "${WG_IF}" 2>/dev/null || true
    return 1
}

connect_from_candidates() {
    local avoid="${1:-}"
    local -a configs=()
    mapfile -t configs < <(list_configs_random)
    (( ${#configs[@]} > 0 )) || die "No WireGuard configs in ${CONFIG_DIR}"

    local cfg attempts=0
    for cfg in "${configs[@]}"; do
        [[ -n "${avoid}" && "${cfg}" == "${avoid}" && ${#configs[@]} -gt 1 ]] && continue
        attempts=$((attempts + 1))
        echo "Trying relay: $(basename "${cfg}")"
        if connect_config "${cfg}"; then
            return 0
        fi
        (( attempts >= 5 )) && break
    done
    return 1
}

ensure_locked() {
    setup_namespace
    if connected; then
        return 0
    fi

    local cur=""
    cur="$(current_config || true)"
    if [[ -n "${cur}" && -f "${cur}" ]]; then
        echo "Reconnecting current relay: $(basename "${cur}")"
        if connect_config "${cur}"; then
            return 0
        fi
    fi

    connect_from_candidates "${cur}" || die "Unable to establish Mullvad WireGuard tunnel"
}

rotate_locked() {
    setup_namespace

    local now last=0
    now="$(date +%s)"
    if [[ -s "${LAST_ROTATE_FILE}" ]]; then
        last="$(cat "${LAST_ROTATE_FILE}" 2>/dev/null || echo 0)"
    fi

    # Several concurrent yt-dlp jobs can observe the same dying IP at once. The
    # first worker rotates; later workers arriving during this small cooldown use
    # the already-fresh tunnel instead of immediately rotating it again.
    if (( now - last < ROTATE_COOLDOWN )) && connected; then
        echo "Rotation already completed recently; keeping fresh relay."
        return 0
    fi

    local cur=""
    cur="$(current_config || true)"
    connect_from_candidates "${cur}" || die "Unable to rotate Mullvad WireGuard tunnel"
    date +%s > "${LAST_ROTATE_FILE}"
    chmod 600 "${LAST_ROTATE_FILE}"
}

with_lock() {
    exec 9>"${LOCK_FILE}"
    flock -x 9
    "$@"
}

command="${1:-}"
[[ -n "${command}" ]] || die "Usage: ytpdl-vpn namespace|ensure|rotate|status|ip|down|exec -- COMMAND..."
shift || true

case "${command}" in
    namespace)
        setup_namespace
        ;;
    ensure)
        with_lock ensure_locked
        ;;
    rotate)
        with_lock rotate_locked
        ;;
    status)
        if connected; then
            echo "Connected"
            if [[ -s "${CURRENT_FILE}" ]]; then
                echo "Relay config: $(basename "$(cat "${CURRENT_FILE}")")"
            fi
            ip netns exec "${NS}" curl -fsS --connect-timeout 4 --max-time 8 "${CHECK_URL}" || true
            exit 0
        fi
        echo "Disconnected" >&2
        exit 1
        ;;
    ip)
        connected || die "VPN namespace is not connected"
        exec ip netns exec "${NS}" curl -fsS --connect-timeout 4 --max-time 8 "${IP_URL}"
        ;;
    down)
        with_lock down_locked
        ;;
    exec)
        [[ "${1:-}" == "--" ]] && shift
        (( $# > 0 )) || die "exec requires a command"
        wg_exists || die "VPN namespace is not connected"
        exec ip netns exec "${NS}" "$@"
        ;;
    *)
        die "Unknown command '${command}'"
        ;;
esac
EOF2
chmod 755 "${VPN_HELPER}"

cat > /etc/systemd/system/ytpdl-vpn-namespace.service <<EOF2
[Unit]
Description=ytp-dl isolated network namespace
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=${VPN_HELPER} namespace
ExecStop=${VPN_HELPER} down
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF2

systemctl daemon-reload
systemctl enable --now ytpdl-vpn-namespace.service

log "4) Install Deno system-wide"
if ! command -v deno >/dev/null 2>&1; then
    curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- --yes --no-modify-path
fi

log "5) Install ytp-dl ${YTPDL_VERSION} and latest yt-dlp pre-release"
mkdir -p "${APP_DIR}"
if [[ ! -d "${VENV_DIR}" ]]; then
    python3 -m venv "${VENV_DIR}"
fi
source "${VENV_DIR}/bin/activate"
pip install --upgrade pip
pip install --upgrade "ytp-dl==${YTPDL_VERSION}"
pip install --upgrade --pre "yt-dlp[default,curl-cffi]"
if [[ "${YTPDL_R2_UPLOAD}" == "1" ]]; then
    pip install --upgrade boto3
fi
deactivate

log "6) Configure API environment"
cat > /etc/default/ytp-dl-api <<EOF2
YTPDL_MAX_CONCURRENT=${YTPDL_MAX_CONCURRENT}
YTPDL_VENV=${VENV_DIR}
YTPDL_VPN_HELPER=${VPN_HELPER}
YTPDL_SLOT_DIR=/run/ytpdl-slots
YTPDL_ACTIVE_LOCK_DIR=/run/ytpdl-active
YTPDL_MIN_FREE_DISK_MB=${YTPDL_MIN_FREE_DISK_MB}
YTPDL_R2_ZIP_PART_SIZE_MB=${YTPDL_R2_ZIP_PART_SIZE_MB}
GUNICORN_WORKERS=${GUNICORN_WORKERS}
GUNICORN_THREADS=${GUNICORN_THREADS}
YTPDL_R2_UPLOAD=${YTPDL_R2_UPLOAD}
R2_ENDPOINT=${R2_ENDPOINT}
R2_BUCKET=${R2_BUCKET}
R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
AWS_EC2_METADATA_DISABLED=true
YTPDL_VPS_API_TOKEN=${YTPDL_VPS_API_TOKEN}
EOF2
chmod 600 /etc/default/ytp-dl-api
mkdir -p /run/ytpdl-slots /run/ytpdl-active
chmod 700 /run/ytpdl-slots /run/ytpdl-active

log "7) Install Gunicorn systemd service"
cat > /etc/systemd/system/ytp-dl-api.service <<EOF2
[Unit]
Description=Gunicorn for ytp-dl API
After=network-online.target ytpdl-vpn-namespace.service
Wants=network-online.target ytpdl-vpn-namespace.service

[Service]
User=root
WorkingDirectory=${APP_DIR}
EnvironmentFile=/etc/default/ytp-dl-api
Environment=VIRTUAL_ENV=${VENV_DIR}
Environment=PATH=${VENV_DIR}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
RuntimeDirectory=ytpdl-slots ytpdl-active
RuntimeDirectoryMode=0700
ExecStart=${VENV_DIR}/bin/gunicorn \
    -k gthread \
    -w ${GUNICORN_WORKERS} \
    --threads ${GUNICORN_THREADS} \
    --timeout 0 \
    --graceful-timeout 15 \
    --keep-alive 20 \
    --bind 0.0.0.0:${PORT} \
    scripts.api:app
Restart=always
RestartSec=3
LimitNOFILE=65535
MemoryMax=${YTPDL_MEMORY_MAX}

[Install]
WantedBy=multi-user.target
EOF2

systemctl daemon-reload
systemctl enable --now ytp-dl-api.service
sleep 3

log "8) Verify API and isolated VPN"
curl -fsS --connect-timeout 5 "http://127.0.0.1:${PORT}/healthz"
echo

# This test touches only the isolated namespace. It must not alter the host's
# default route, SSH connectivity or public API route.
if "${VPN_HELPER}" ensure; then
    echo "VPN namespace check: OK"
    "${VPN_HELPER}" status || true
    echo -n "VPN exit IP: "
    "${VPN_HELPER}" ip || true
    echo
else
    echo "WARNING: API is installed, but the isolated Mullvad tunnel test failed." >&2
    echo "Check: ${VPN_HELPER} status" >&2
fi

log "Installation complete"
echo "API: http://0.0.0.0:${PORT}"
echo "Global download capacity: ${YTPDL_MAX_CONCURRENT} jobs"
echo "Free-disk reserve: ${YTPDL_MIN_FREE_DISK_MB} MB"
echo "Gunicorn: ${GUNICORN_WORKERS} workers x ${GUNICORN_THREADS} threads"
echo "Service memory limit: ${YTPDL_MEMORY_MAX}"
echo "VPN namespace: ${VPN_NAMESPACE}"
echo "Mullvad relay filter: ${YTPDL_MULLVAD_LOCATION}"
echo
echo "Useful commands:"
echo "  systemctl status ytp-dl-api.service --no-pager"
echo "  journalctl -u ytp-dl-api.service -f"
echo "  ${VPN_HELPER} status"
echo "  ${VPN_HELPER} ip"
echo "  ${VPN_HELPER} rotate"
echo "  ip netns exec ${VPN_NAMESPACE} ip addr"
```
