commit 75b071c5becbb1e2d3ca9fcbed79d50070465f33 Author: Space-Banane Date: Mon Apr 6 23:00:10 2026 +0200 Initial commit: add core functionality for Patreon media downloading with UI and batch processing support diff --git a/MIT-0.txt b/MIT-0.txt new file mode 100644 index 0000000..f19aaa6 --- /dev/null +++ b/MIT-0.txt @@ -0,0 +1,14 @@ +MIT No Attribution + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1424ee --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Patreon Downloader + +Lightweight helpers for using `yt-dlp` to download media from Patreon posts you have access to. + +## Contents +- `with_ui.py` + `frontend.html` — browser-based UI for inspecting post metadata and streaming downloads. +- `batch.py` — command-line batch downloader (supports `urls.json`). +- `urls.sample.json` — example JSON input file. + +## Features +- Inspect available formats and metadata before downloading. +- Stream `yt-dlp` output to the browser with live progress. +- Batch-mode support for many posts via a JSON file or inline list. + +## Requirements +- Python 3.8+ and `pip`. +- `yt-dlp` installed and available on PATH (or configure via environment variables). + +## Installation +1. Clone the repository: + +```bash +git clone +cd patreon-downloader +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Export your Patreon cookies to `cookies.txt` (use a browser extension such as “Get cookies.txt”) and place it in the project root. + +## Quick Start + +### Web UI (recommended) +1. Start the UI server: + +```bash +python with_ui.py +``` + +2. Open http://localhost:5000 in your browser. +3. Fill in the post URL and the path to `cookies.txt`. Inspect formats and click download. The terminal-like pane shows `yt-dlp` output live. + +### Batch mode +You can provide URLs in two ways: + +- Inline in `batch.py`: edit the `URLS` list. +- JSON file: create `urls.json` (or set `URLS_JSON`) containing an array of URLs. + +Example `urls.json` format (see `urls.sample.json`): + +```json +["https://www.patreon.com/posts/example-post-12345678", "https://www.patreon.com/posts/another-example-87654321"] +``` + +Run the batch script: + +```bash +python batch.py +``` + +## Configuration +- `YTDLP_PATH` (env): override `yt-dlp` executable for the UI server. +- `DOWNLOAD_DIR` (env): override download directory used by the UI server. +- `URLS_JSON` (env): path to a JSON file containing an array of URLs for `batch.py` (default: `./urls.json`). + +## JSON input behavior (batch) +- If `URLS_JSON` exists and contains a non-empty array of strings, `batch.py` will use those URLs. +- If not present, `batch.py` falls back to the `URLS` list embedded in the script. + +## Examples +- Run single download quickly via UI. +- Use `urls.sample.json` as a template for bulk downloads. + +## Troubleshooting +- `yt-dlp` extractor or permission errors: re-export `cookies.txt` and ensure they are up to date. +- UI fetch timeout: the UI uses a 30s timeout for `--dump-json`; run the `yt-dlp --dump-json` command manually to debug. +- If downloads fail on Windows, ensure `YTDLP` in `batch.py` points to `yt-dlp.exe`. + +## Security & Ethics +- Use this tool only for content you are authorized to access (e.g., from subscriptions you paid for). Respect creators' terms. + +## Contributing +- Bug reports and pull requests welcome. Keep changes minimal and focused. + +## Files of interest +- `with_ui.py` — Flask UI server and job manager. +- `frontend.html` — browser interface. +- `batch.py` — command-line batch downloader (now supports JSON input). +- `urls.sample.json` — example JSON file. +- `requirements.txt` — Python dependencies. + +## License +- MIT No Attribution — see `MIT-0.txt`. \ No newline at end of file diff --git a/batch.py b/batch.py new file mode 100644 index 0000000..6da9ba6 --- /dev/null +++ b/batch.py @@ -0,0 +1,154 @@ +import subprocess +import json +from pathlib import Path +import os + +COOKIES = "cookies.txt" # Get them using the browser extension "Get cookies.txt" and export them while logged in to your account. +YTDLP = "yt-dlp.exe" # Change this if you aren't on Windows. Get the latest release from github +OUTPUT_DIR = Path(__file__).parent / "YOUR_OUTPUT_FOLDER" + +# Input options: +# - If `urls.json` exists in the project root it will be used. The JSON should be an array of strings (URLs) +# - Otherwise set URLs below or pass a custom JSON path via the `URLS_JSON` environment variable. +URLS = [ + # fallback list — leave empty if you'll use urls.json +] + +URLS_JSON = os.environ.get("URLS_JSON", str(Path(__file__).parent / "urls.json")) + +RESET = "\033[0m" +BOLD = "\033[1m" +RED = "\033[91m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +CYAN = "\033[96m" +DIM = "\033[2m" + +def log(msg, color=RESET): + print(f"{color}{msg}{RESET}", flush=True) + +def get_post_id(url): + return url.rstrip("/").split("-")[-1] + +def fetch_info(url): + cmd = [ + YTDLP, + "--dump-json", "--no-playlist", + "--cookies", COOKIES, + "--extractor-args", "generic:impersonate", + url + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=Path(__file__).parent) + if result.returncode != 0: + return None, result.stderr.strip() + try: + return json.loads(result.stdout), None + except json.JSONDecodeError: + return None, "Failed to parse JSON" + +def download(url, out_dir): + cmd = [ + YTDLP, + "-f", "bestvideo+bestaudio/best", + "--prefer-free-formats", + "--cookies", COOKIES, + "--extractor-args", "generic:impersonate", + "--merge-output-format", "mp4", + "-o", str(out_dir / "%(title)s.%(ext)s"), + url + ] + result = subprocess.run(cmd, capture_output=True, text=True, cwd=Path(__file__).parent) + return result.returncode == 0, (result.stdout + result.stderr).strip() + +def sanitize(name, max_len=60): + safe = "".join(c if c.isalnum() or c in " _-." else "_" for c in name) + return safe.strip()[:max_len].strip("_. ") + +def main(): + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + total = len(URLS) + # Load from JSON if present and non-empty + try: + if Path(URLS_JSON).exists(): + with open(URLS_JSON, 'r', encoding='utf-8') as fh: + data = json.load(fh) + if isinstance(data, list) and data: + URLS.clear() + URLS.extend([u for u in data if isinstance(u, str) and u.strip()]) + total = len(URLS) + except Exception as e: + log(f"Failed to load URLs from {URLS_JSON}: {e}", YELLOW) + + ok, skipped, failed = [], [], [] + + log(f"\n{'━'*55}", CYAN) + log(f" yt-dlp batch — {total} posts → {OUTPUT_DIR.name}/", BOLD) + log(f"{'━'*55}\n", CYAN) + + for i, url in enumerate(URLS, 1): + post_id = get_post_id(url) + log(f"[{i:02d}/{total}] {DIM}{url}{RESET}") + + # Fetch metadata first to get the title + log(f" fetching info…", DIM) + info, err = fetch_info(url) + + if info is None: + # Likely a text post or unavailable + log(f" {YELLOW}⚠ skipped — no media ({err[:80] if err else 'no video found'}){RESET}") + skipped.append((url, err)) + print() + continue + + title = info.get("title") or f"post_{post_id}" + folder_name = sanitize(title) + out_dir = OUTPUT_DIR / folder_name + out_dir.mkdir(parents=True, exist_ok=True) + + log(f" title: {BOLD}{title[:55]}{RESET}") + log(f" dir: {out_dir}") + log(f" downloading…", DIM) + + success, output = download(url, out_dir) + + if success: + # Find what was downloaded + files = list(out_dir.iterdir()) + sizes = [f"{f.stat().st_size / 1e6:.1f} MB" for f in files if f.is_file()] + log(f" {GREEN}✓ done — {', '.join(sizes) if sizes else 'file saved'}{RESET}") + ok.append(url) + else: + # Check if it's just no video (text post that slipped through info check) + if "no video" in output.lower() or "no formats" in output.lower(): + log(f" {YELLOW}⚠ skipped — text post{RESET}") + skipped.append((url, "no video content")) + # Remove empty dir + try: out_dir.rmdir() + except OSError: pass + else: + log(f" {RED}✗ failed{RESET}") + # Print last few lines of output for context + for line in output.splitlines()[-3:]: + log(f" {DIM}{line}{RESET}") + failed.append((url, output.splitlines()[-1] if output else "unknown error")) + + print() + + # Summary + log(f"{'━'*55}", CYAN) + log(f" Summary", BOLD) + log(f"{'━'*55}", CYAN) + log(f" {GREEN}✓ downloaded: {len(ok)}{RESET}") + log(f" {YELLOW}⚠ skipped (text/no media): {len(skipped)}{RESET}") + log(f" {RED}✗ failed: {len(failed)}{RESET}") + + if failed: + log(f"\n Failed URLs:", RED) + for url, reason in failed: + log(f" • {url}", RED) + log(f" {DIM}{reason[:80]}{RESET}") + + log(f"{'━'*55}\n", CYAN) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/frontend.html b/frontend.html new file mode 100644 index 0000000..2d964ae --- /dev/null +++ b/frontend.html @@ -0,0 +1,361 @@ + + + + + + yt-dlp UI + + + + + + + + +
+
+
+

yt-dlp UI

+
+

Because Patreon won't let you download videos directly (for whatever reason)

+
+ +
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..70114e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask +flask-cors \ No newline at end of file diff --git a/urls.json b/urls.json new file mode 100644 index 0000000..45640f7 --- /dev/null +++ b/urls.json @@ -0,0 +1,4 @@ +[ + "https://www.patreon.com/posts/example-post-12345678", + "https://www.patreon.com/posts/another-example-87654321" +] diff --git a/with_ui.py b/with_ui.py new file mode 100644 index 0000000..edd5b64 --- /dev/null +++ b/with_ui.py @@ -0,0 +1,154 @@ +import subprocess +import threading +import uuid +import json +import os +from flask import Flask, request, jsonify, Response, stream_with_context +from flask_cors import CORS +import time + +app = Flask(__name__, static_folder='.', static_url_path='') +CORS(app) + +# Store active jobs: {job_id: {"lines": [], "done": bool, "error": bool}} +jobs = {} +jobs_lock = threading.Lock() + +YTDLP_PATH = os.environ.get("YTDLP_PATH", "yt-dlp") +DOWNLOAD_DIR = os.environ.get("DOWNLOAD_DIR", os.getcwd()) + + +def run_ytdlp(job_id, cmd): + with jobs_lock: + jobs[job_id] = {"lines": [], "done": False, "error": False} + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=DOWNLOAD_DIR + ) + for line in process.stdout: + line = line.rstrip() + with jobs_lock: + jobs[job_id]["lines"].append(line) + process.wait() + with jobs_lock: + jobs[job_id]["done"] = True + if process.returncode != 0: + jobs[job_id]["error"] = True + except Exception as e: + with jobs_lock: + jobs[job_id]["lines"].append(f"[ERROR] {str(e)}") + jobs[job_id]["done"] = True + jobs[job_id]["error"] = True + + +@app.route('/') +def index(): + return app.send_static_file('frontend.html') + + +@app.route('/api/info', methods=['POST']) +def get_info(): + data = request.json + url = data.get('url', '').strip() + cookies = data.get('cookies', '').strip() + + if not url: + return jsonify({"error": "No URL provided"}), 400 + + cmd = [YTDLP_PATH, '--dump-json', '--no-playlist'] + if cookies: + cmd += ['--cookies', cookies] + cmd += ['--extractor-args', 'generic:impersonate', url] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=DOWNLOAD_DIR) + if result.returncode != 0: + return jsonify({"error": result.stderr or "Failed to fetch info"}), 400 + info = json.loads(result.stdout) + formats = [] + for f in info.get('formats', []): + formats.append({ + "id": f.get('format_id'), + "ext": f.get('ext'), + "resolution": f.get('resolution') or f.get('format_note') or '', + "vcodec": f.get('vcodec', 'none'), + "acodec": f.get('acodec', 'none'), + "filesize": f.get('filesize') or f.get('filesize_approx'), + "tbr": f.get('tbr'), + "label": f.get('format'), + }) + return jsonify({ + "title": info.get('title', 'Unknown'), + "thumbnail": info.get('thumbnail'), + "duration": info.get('duration'), + "uploader": info.get('uploader'), + "formats": formats + }) + except subprocess.TimeoutExpired: + return jsonify({"error": "Timed out fetching video info"}), 408 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/download', methods=['POST']) +def start_download(): + data = request.json + url = data.get('url', '').strip() + format_id = data.get('format_id', '').strip() + cookies = data.get('cookies', '').strip() + extra_args = data.get('extra_args', '').strip() + + if not url: + return jsonify({"error": "No URL provided"}), 400 + + cmd = [YTDLP_PATH] + if format_id: + cmd += ['-f', format_id] + cmd += ['--prefer-free-formats'] + if cookies: + cmd += ['--cookies', cookies] + cmd += ['--extractor-args', 'generic:impersonate'] + if extra_args: + cmd += extra_args.split() + cmd.append(url) + + job_id = str(uuid.uuid4()) + thread = threading.Thread(target=run_ytdlp, args=(job_id, cmd), daemon=True) + thread.start() + + return jsonify({"job_id": job_id}) + + +@app.route('/api/status/') +def job_status(job_id): + def generate(): + sent = 0 + while True: + with jobs_lock: + job = jobs.get(job_id) + if not job: + yield f"data: {json.dumps({'error': 'Job not found'})}\n\n" + break + lines = job['lines'] + while sent < len(lines): + yield f"data: {json.dumps({'line': lines[sent]})}\n\n" + sent += 1 + if job['done']: + yield f"data: {json.dumps({'done': True, 'error': job['error']})}\n\n" + break + time.sleep(0.2) + + return Response(stream_with_context(generate()), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + +if __name__ == '__main__': + print(f"[yt-dlp UI] Serving on http://localhost:5000") + print(f"[yt-dlp UI] Download directory: {DOWNLOAD_DIR}") + app.run(debug=False, host='0.0.0.0', port=5000, threaded=True) \ No newline at end of file