diff --git a/Dockerfile b/Dockerfile index d3dfbe9..cd96613 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index f67212c..000688d 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,54 @@ # 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. - +  -https://github.com/user-attachments/assets/419d3e50-c933-444b-8cab-a9724986ba05 - - - ## 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 @@ -41,9 +56,41 @@ docker build -t reclip . && docker run -p 8899:8899 reclip 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: @@ -52,10 +99,10 @@ 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 @@ -63,4 +110,4 @@ This tool is intended for personal use only. Please respect copyright laws and t ## License -[MIT](LICENSE) +[MIT](LICENSE) \ No newline at end of file diff --git a/app.py b/app.py index 703f435..c63f7d7 100644 --- a/app.py +++ b/app.py @@ -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: @@ -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] @@ -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")] @@ -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) @@ -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", ""), @@ -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() @@ -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) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed0581b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/templates/index.html b/templates/index.html index 0bae3d3..ffab1a0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -141,6 +141,35 @@ .fetch-btn:active { transform: scale(0.98); } .fetch-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } + /* Audio format picker */ + #audioFormatSection { + display: none; + margin-top: 8px; + } + .audio-format-group { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .audio-fmt-btn { + padding: 5px 12px; + border: 1.5px solid var(--card-border); + border-radius: 6px; + background: transparent; + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 0.65rem; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + } + .audio-fmt-btn.active { + border-color: var(--accent); + color: var(--accent); + background: rgba(232, 93, 42, 0.06); + } + .audio-fmt-btn:not(.active):hover { border-color: var(--muted); } + /* Cards */ .cards { margin-top: 24px; } @@ -175,7 +204,6 @@ object-fit: cover; display: block; } - /* No-thumbnail icon */ .card-thumb .no-thumb { width: 100%; height: 100%; @@ -190,7 +218,6 @@ opacity: 0.5; } - /* Shimmer skeleton for loading state */ .card-thumb.loading { background: var(--shimmer); background-size: 200% 100%; @@ -270,6 +297,7 @@ .card-status.done { color: var(--success); } .card-status.error { color: var(--error); } + /* Quality chips */ .q-chip { padding: 4px 10px; border: 1.5px solid var(--card-border); @@ -288,6 +316,33 @@ } .q-chip:hover:not(.active) { border-color: var(--muted); } + /* Codec + format rows (vertical layout) */ + .format-rows { display: flex; flex-direction: column; gap: 4px; } + .format-row { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; } + .codec-sep { + font-size: 0.6rem; + color: var(--muted); + padding: 0 2px; + user-select: none; + } + .codec-chip { + padding: 2px 8px; + border: 1.5px solid var(--card-border); + border-radius: 5px; + background: transparent; + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 0.6rem; + cursor: pointer; + transition: all 0.2s; + } + .codec-chip.active { + border-color: var(--accent); + color: var(--accent); + background: rgba(232, 93, 42, 0.06); + } + .codec-chip:hover:not(.active) { border-color: var(--muted); } + /* Error card */ .card.card-error { border-color: rgba(196, 61, 61, 0.2); @@ -364,7 +419,54 @@ line-height: 1.8; } - /* Mobile */ + /* Auth banner (uBlock-safe: no "cookie" in class/id names) */ + #authSection { margin-top: 12px; } + .auth-banner { + border: 1.5px solid rgba(232, 93, 42, 0.3); + border-radius: var(--radius); + background: rgba(232, 93, 42, 0.05); + padding: 12px 16px; + } + .auth-banner-text { + font-size: 0.72rem; + color: var(--fg); + margin-bottom: 8px; + line-height: 1.5; + } + .auth-banner-text a { + color: var(--accent); + text-decoration: underline; + } + .auth-upload-row { + display: flex; + gap: 8px; + align-items: center; + } + .auth-upload-btn { + padding: 6px 14px; + border: 1.5px solid var(--accent); + border-radius: 7px; + background: transparent; + color: var(--accent); + font-family: 'DM Mono', monospace; + font-size: 0.68rem; + cursor: pointer; + transition: all 0.2s; + } + .auth-upload-btn:hover { background: rgba(232, 93, 42, 0.1); } + .auth-clear-btn { + padding: 6px 14px; + border: 1.5px solid var(--card-border); + border-radius: 7px; + background: transparent; + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 0.68rem; + cursor: pointer; + transition: all 0.2s; + } + .auth-clear-btn:hover { border-color: var(--muted); color: var(--fg); } + @media (max-width: 500px) { .brand h1 { font-size: 3rem; } .page { padding: 40px 16px 60px; } @@ -398,6 +500,32 @@