Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
FROM python:3.12-slim

RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
apt-get install -y --no-install-recommends ffmpeg curl unzip && \
rm -rf /var/lib/apt/lists/*

# Install Deno (yt-dlp needs it for YouTube JS challenges)
RUN curl -fsSL https://deno.land/install.sh | sh && \
mv /root/.deno/bin/deno /usr/local/bin/deno

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Install latest yt-dlp from GitHub (PyPI is often outdated)
RUN pip install --no-cache-dir --upgrade https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz

COPY . .

EXPOSE 8899
Expand Down
85 changes: 66 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,96 @@
# ReClip

A self-hosted, open-source video and audio downloader with a clean web UI. Paste links from YouTube, TikTok, Instagram, Twitter/X, and 1000+ other sites — download as MP4 or MP3.
A self-hosted, open-source video and audio downloader with a clean web UI. Paste links from YouTube, TikTok, Instagram, Twitter/X, and 1000+ other sites — download as MP4 or extract audio in multiple formats.

![Python](https://img.shields.io/badge/python-3.8+-blue)
![Python](https://img.shields.io/badge/python-3.12-blue)
![License](https://img.shields.io/badge/license-MIT-green)

https://github.com/user-attachments/assets/419d3e50-c933-444b-8cab-a9724986ba05

![ReClip MP3 Mode](assets/preview-mp3.png)

## Features

- Download videos from 1000+ supported sites (via [yt-dlp](https://github.com/yt-dlp/yt-dlp))
- MP4 video or MP3 audio extraction
- Quality/resolution picker
- MP4 video or audio extraction in multiple formats (MP3, AAC, OPUS, FLAC, WAV, M4A)
- Quality/resolution picker with codec selection (AVC1, VP9, AV01)
- Bulk downloads — paste multiple URLs at once
- Automatic URL deduplication
- Cookie-based authentication for age-restricted or bot-protected videos
- Clean, responsive UI — no frameworks, no build step
- Single Python file backend (~150 lines)
- Single Python file backend

## Quick Start

### Local

```bash
brew install yt-dlp ffmpeg # or apt install ffmpeg && pip install yt-dlp
git clone https://github.com/averygan/reclip.git
cd reclip
./reclip.sh
# Prerequisites: Python 3.12+, ffmpeg, Deno
brew install ffmpeg deno # macOS
# or: apt install ffmpeg && curl -fsSL https://deno.land/install.sh | sh # Linux

pip install -r requirements.txt
python app.py
```

Open **http://localhost:8899**.

Or with Docker:
### Docker

```bash
docker build -t reclip . && docker run -p 8899:8899 reclip
docker build -t reclip .
docker run -p 8899:8899 reclip
```

The Docker image includes ffmpeg and Deno — no extra setup needed.

### Docker Compose (Dokploy / Portainer)

```yaml
services:
reclip:
build: .
ports:
- "8899:8899"
restart: unless-stopped
```

## Usage

1. Paste one or more video URLs into the input box
2. Choose **MP4** (video) or **MP3** (audio)
3. Click **Fetch** to load video info and thumbnails
4. Select quality/resolution if available
4. Select quality/resolution (and codec if multiple are available)
5. Click **Download** on individual videos, or **Download All**

### Authentication (Cookies)

Some YouTube videos require authentication. If a fetch fails with a "Sign in to confirm you're not a bot" error, an auth upload option will appear automatically.

**Getting your YouTube cookies:**

1. Open a **private/incognito** browser window
2. Log into **youtube.com** in that window
3. In the **same tab**, navigate to `https://www.youtube.com/robots.txt`
4. Install the **EditThisCookie** browser extension ([Chrome](https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhfbceomclgfbg) / [Firefox](https://addons.mozilla.org/en-US/firefox/addon/editthiscookie-2/))
5. Click the EditThisCookie icon → **Export** (copies JSON cookies to clipboard)
6. Paste into a `.txt` file and save
7. Upload the file via the in-app "Choose session file" button
8. **Close the private window immediately** so the session cookies are never rotated

> **Why private/incognito?** YouTube rotates account cookies on open browser tabs as a security measure. Exporting from an active session may result in already-invalid cookies. The private window technique ensures cookies remain valid.

**Privacy:** Uploaded cookies are stored in RAM only (sessionStorage) and are never written to disk on the server. They are automatically deleted when you close the browser tab. The server writes them to a temporary file for the duration of the yt-dlp subprocess only, then deletes it.

**JSON and Netscape formats are both supported** — EditThisCookie exports JSON, which is automatically converted to Netscape format internally.

## Requirements

| Component | Purpose |
|-----------|---------|
| **Python 3.12+** | Backend runtime |
| **ffmpeg** | Audio/video merging and transcoding |
| **Deno** | JavaScript runtime for YouTube challenge solving |
| **yt-dlp** | Download engine |

> **Why Deno?** YouTube requires solving JavaScript challenges to access video streams. yt-dlp uses Deno (or Node.js) to execute these challenges. Without a JS runtime, only image/storyboard formats will be available.

## Supported Sites

Anything [yt-dlp supports](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md), including:
Expand All @@ -52,15 +99,15 @@ YouTube, TikTok, Instagram, Twitter/X, Reddit, Facebook, Vimeo, Twitch, Dailymot

## Stack

- **Backend:** Python + Flask (~150 lines)
- **Backend:** Python + Flask
- **Frontend:** Vanilla HTML/CSS/JS (single file, no build step)
- **Download engine:** [yt-dlp](https://github.com/yt-dlp/yt-dlp) + [ffmpeg](https://ffmpeg.org/)
- **Dependencies:** 2 (Flask, yt-dlp)
- **JS runtime:** [Deno](https://deno.com/) (for YouTube challenge solving)

## Disclaimer

This tool is intended for personal use only. Please respect copyright laws and the terms of service of the platforms you download from. The developers are not responsible for any misuse of this tool.

## License

[MIT](LICENSE)
[MIT](LICENSE)
119 changes: 103 additions & 16 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,91 @@
import os
import sys
import uuid
import glob
import json
import subprocess
import tempfile
import threading
from flask import Flask, request, jsonify, send_file, render_template

# Force stdout/stderr flush for Docker logging
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)

app = Flask(__name__)
DOWNLOAD_DIR = os.path.join(os.path.dirname(__file__), "downloads")
os.makedirs(DOWNLOAD_DIR, exist_ok=True)

jobs = {}

AUDIO_FORMATS = ["mp3", "aac", "opus", "flac", "wav", "m4a"]


def run_download(job_id, url, format_choice, format_id):
def _cookie_to_tmp(cookie_content):
"""Write cookie content to a temp file in Netscape format, return path. Caller must clean up."""
if not cookie_content:
return None

# If JSON (EditThisCookie export), convert to Netscape format
try:
parsed = json.loads(cookie_content)
if isinstance(parsed, list):
lines = ["# Netscape HTTP Cookie File", ""]
for c in parsed:
domain = c.get("domain", "")
flag = "TRUE" if domain.startswith(".") else "FALSE"
path = c.get("path", "/")
secure = "TRUE" if c.get("secure", False) else "FALSE"
exp = c.get("expirationDate") or c.get("expiry") or 0
expiry = str(int(float(exp))) if exp else "0"
name = c.get("name", "")
value = c.get("value", "")
if domain and name:
http_only = "#HttpOnly_" if c.get("httpOnly", False) else ""
lines.append(f"{http_only}{domain}\t{flag}\t{path}\t{secure}\t{expiry}\t{name}\t{value}")
cookie_content = "\n".join(lines)
except (json.JSONDecodeError, ValueError, KeyError):
pass # Not JSON, assume already Netscape format

fd, path = tempfile.mkstemp(suffix=".txt", prefix="reclip_cookies_")
try:
os.write(fd, cookie_content.encode("utf-8"))
os.close(fd)
return path
except Exception:
try:
os.close(fd)
except OSError:
pass
if os.path.exists(path):
os.remove(path)
return None


def _cleanup_cookie(path):
if path and os.path.exists(path):
try:
os.remove(path)
except OSError:
pass


def run_download(job_id, url, format_choice, format_id, audio_format="mp3", cookie_content=None):
job = jobs[job_id]
out_template = os.path.join(DOWNLOAD_DIR, f"{job_id}.%(ext)s")

cmd = ["yt-dlp", "--no-playlist", "-o", out_template]
cmd = ["yt-dlp", "--no-playlist", "--remote-components", "ejs:npm", "-o", out_template]

# Add cookies if provided
cookie_path = None
if cookie_content:
cookie_path = _cookie_to_tmp(cookie_content)
if cookie_path:
cmd += ["--cookies", cookie_path]

if format_choice == "audio":
cmd += ["-x", "--audio-format", "mp3"]
safe_audio_fmt = audio_format if audio_format in AUDIO_FORMATS else "mp3"
cmd += ["-x", "--audio-format", safe_audio_fmt]
elif format_id:
cmd += ["-f", f"{format_id}+bestaudio/best", "--merge-output-format", "mp4"]
else:
Expand All @@ -30,6 +95,8 @@ def run_download(job_id, url, format_choice, format_id):

try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if cookie_path:
_cleanup_cookie(cookie_path)
if result.returncode != 0:
job["status"] = "error"
job["error"] = result.stderr.strip().split("\n")[-1]
Expand All @@ -42,7 +109,8 @@ def run_download(job_id, url, format_choice, format_id):
return

if format_choice == "audio":
target = [f for f in files if f.endswith(".mp3")]
safe_audio_fmt = audio_format if audio_format in AUDIO_FORMATS else "mp3"
target = [f for f in files if f.endswith(f".{safe_audio_fmt}")]
chosen = target[0] if target else files[0]
else:
target = [f for f in files if f.endswith(".mp4")]
Expand All @@ -59,7 +127,6 @@ def run_download(job_id, url, format_choice, format_id):
job["file"] = chosen
ext = os.path.splitext(chosen)[1]
title = job.get("title", "").strip()
# Sanitize title for filename
if title:
safe_title = "".join(c for c in title if c not in r'\/:*?"<>|').strip()[:20].strip()
job["filename"] = f"{safe_title}{ext}" if safe_title else os.path.basename(chosen)
Expand All @@ -85,31 +152,43 @@ def get_info():
if not url:
return jsonify({"error": "No URL provided"}), 400

cmd = ["yt-dlp", "--no-playlist", "-j", url]
cmd = ["yt-dlp", "--no-playlist", "--remote-components", "ejs:npm", "-j", url]
cookie_content = data.get("cookie")
cookie_path = None
if cookie_content:
cookie_path = _cookie_to_tmp(cookie_content)
if cookie_path:
cmd += ["--cookies", cookie_path]

try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if cookie_path:
_cleanup_cookie(cookie_path)
if result.returncode != 0:
return jsonify({"error": result.stderr.strip().split("\n")[-1]}), 400

info = json.loads(result.stdout)

# Build quality options — keep best format per resolution
best_by_height = {}
# Build quality options — group by height+codec, keep best tbr per group
best_by_key = {}
for f in info.get("formats", []):
height = f.get("height")
if height and f.get("vcodec", "none") != "none":
vcodec = f.get("vcodec", "none")
if height and vcodec != "none":
codec_label = vcodec.split(".")[0]
key = (height, codec_label)
tbr = f.get("tbr") or 0
if height not in best_by_height or tbr > (best_by_height[height].get("tbr") or 0):
best_by_height[height] = f
if key not in best_by_key or tbr > (best_by_key[key].get("tbr") or 0):
best_by_key[key] = f

formats = []
for height, f in best_by_height.items():
for (height, codec_label), f in sorted(best_by_key.items(), key=lambda x: (-x[0][0], x[0][1])):
formats.append({
"id": f["format_id"],
"label": f"{height}p",
"height": height,
"codec": codec_label,
})
formats.sort(key=lambda x: x["height"], reverse=True)

return jsonify({
"title": info.get("title", ""),
Expand All @@ -130,15 +209,23 @@ def start_download():
url = data.get("url", "").strip()
format_choice = data.get("format", "video")
format_id = data.get("format_id")
audio_format = data.get("audio_format", "mp3")
title = data.get("title", "")
cookie_content = data.get("cookie")

if not url:
return jsonify({"error": "No URL provided"}), 400

if audio_format not in AUDIO_FORMATS:
audio_format = "mp3"

job_id = uuid.uuid4().hex[:10]
jobs[job_id] = {"status": "downloading", "url": url, "title": title}

thread = threading.Thread(target=run_download, args=(job_id, url, format_choice, format_id))
thread = threading.Thread(
target=run_download,
args=(job_id, url, format_choice, format_id, audio_format, cookie_content),
)
thread.daemon = True
thread.start()

Expand All @@ -162,10 +249,10 @@ def download_file(job_id):
job = jobs.get(job_id)
if not job or job["status"] != "done":
return jsonify({"error": "File not ready"}), 404
return send_file(job["file"], as_attachment=True, download_name=job["filename"])
return send_file(job["file"], as_attachment=True, download_name=job.get("filename"))


if __name__ == "__main__":
port = int(os.environ.get("PORT", 8899))
host = os.environ.get("HOST", "127.0.0.1")
app.run(host=host, port=port)
app.run(host=host, port=port)
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
reclip:
build:
context: https://github.com/LibreArbitre/reclip.git#main
dockerfile: Dockerfile
container_name: reclip
restart: unless-stopped
environment:
- PORT=8899
- HOST=0.0.0.0
volumes:
- /app/downloads
Loading