Initial commit: add core functionality for Patreon media downloading with UI and batch processing support
This commit is contained in:
14
MIT-0.txt
Normal file
14
MIT-0.txt
Normal file
@@ -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.
|
||||
97
README.md
Normal file
97
README.md
Normal file
@@ -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 <repo-url>
|
||||
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`.
|
||||
154
batch.py
Normal file
154
batch.py
Normal file
@@ -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()
|
||||
361
frontend.html
Normal file
361
frontend.html
Normal file
@@ -0,0 +1,361 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>yt-dlp UI</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Space Grotesk', sans-serif; background: #0a0a0f; color: #e2e8f0; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.glass {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.accent { color: #a78bfa; }
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.15); transform: translateY(-1px); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
.terminal {
|
||||
background: #050508;
|
||||
border: 1px solid rgba(167,139,250,0.2);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.format-row { transition: background 0.15s; }
|
||||
.format-row:hover { background: rgba(167,139,250,0.08); }
|
||||
.format-row.selected { background: rgba(124,58,237,0.2); border-color: rgba(124,58,237,0.5); }
|
||||
input, textarea {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124,58,237,0.15);
|
||||
}
|
||||
.progress-bar {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #7c3aed, #818cf8);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.tag {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
.glow { text-shadow: 0 0 20px rgba(167,139,250,0.5); }
|
||||
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||||
.pulse-dot { animation: pulse-dot 1s infinite; }
|
||||
@keyframes slide-in { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
|
||||
.slide-in { animation: slide-in 0.25s ease forwards; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen p-4 md:p-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="max-w-4xl mx-auto mb-8">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<div class="w-2 h-2 rounded-full bg-violet-400 pulse-dot"></div>
|
||||
<h1 class="text-2xl font-bold glow">yt-dlp <span class="accent">UI</span></h1>
|
||||
</div>
|
||||
<p class="text-slate-500 mono text-xs ml-5">Because Patreon won't let you download videos directly (for whatever reason)</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-4">
|
||||
|
||||
<!-- Input Card -->
|
||||
<div class="glass rounded-2xl p-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2">URL</label>
|
||||
<input id="urlInput" type="text" placeholder="https://www.patreon.com/posts/..."
|
||||
class="w-full rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 mono" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2">Cookies file</label>
|
||||
<input id="cookiesInput" type="text" placeholder="cookies.txt"
|
||||
class="w-full rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2">Extra args <span class="normal-case text-slate-600">(optional)</span></label>
|
||||
<input id="extraArgs" type="text" placeholder="--write-subs --embed-thumbnail"
|
||||
class="w-full rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 mono" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button onclick="fetchInfo()" class="btn-primary text-white font-semibold px-6 py-2.5 rounded-xl text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Fetch formats
|
||||
</button>
|
||||
<button onclick="downloadDirect()" class="glass text-slate-300 hover:text-white font-semibold px-6 py-2.5 rounded-xl text-sm border border-white/10 hover:border-violet-500/40 transition-all flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Best quality
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Info -->
|
||||
<div id="infoCard" class="glass rounded-2xl p-6 hidden slide-in">
|
||||
<div class="flex gap-4">
|
||||
<img id="thumbnail" src="" alt="" class="w-32 h-20 object-cover rounded-lg flex-shrink-0 hidden" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 id="videoTitle" class="font-bold text-white text-lg leading-tight truncate"></h2>
|
||||
<p id="videoMeta" class="text-slate-500 text-sm mt-1 mono"></p>
|
||||
<p id="formatCount" class="text-xs text-slate-600 mt-1"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formats -->
|
||||
<div id="formatsCard" class="glass rounded-2xl overflow-hidden hidden slide-in">
|
||||
<div class="px-6 py-4 border-b border-white/5 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-slate-300">Available formats</span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="filterFormats('all')" id="fAll" class="text-xs px-3 py-1 rounded-lg bg-violet-600 text-white">All</button>
|
||||
<button onclick="filterFormats('video')" id="fVideo" class="text-xs px-3 py-1 rounded-lg glass text-slate-400 hover:text-white">Video</button>
|
||||
<button onclick="filterFormats('audio')" id="fAudio" class="text-xs px-3 py-1 rounded-lg glass text-slate-400 hover:text-white">Audio</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="formatsTable" class="divide-y divide-white/5 max-h-80 overflow-y-auto"></div>
|
||||
<div class="px-6 py-4 border-t border-white/5 flex items-center justify-between">
|
||||
<span id="selectedLabel" class="mono text-xs text-slate-500">no format selected</span>
|
||||
<button onclick="downloadSelected()" id="dlBtn" disabled
|
||||
class="btn-primary disabled:opacity-40 disabled:cursor-not-allowed disabled:transform-none text-white font-semibold px-6 py-2 rounded-xl text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div id="terminalCard" class="hidden slide-in">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div id="termDot" class="w-2 h-2 rounded-full bg-yellow-400 pulse-dot"></div>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase tracking-widest">Output</span>
|
||||
</div>
|
||||
<div id="progressWrap" class="w-40 hidden">
|
||||
<div class="text-right mono text-xs text-slate-500 mb-1" id="progressLabel"></div>
|
||||
<div class="bg-white/5 rounded-full overflow-hidden"><div id="progressBar" class="progress-bar" style="width:0%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal" class="terminal rounded-xl p-4 h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedFormat = null;
|
||||
let allFormats = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '?';
|
||||
if (bytes > 1e9) return (bytes/1e9).toFixed(1) + ' GB';
|
||||
if (bytes > 1e6) return (bytes/1e6).toFixed(1) + ' MB';
|
||||
return (bytes/1e3).toFixed(0) + ' KB';
|
||||
}
|
||||
|
||||
function formatDuration(s) {
|
||||
if (!s) return '';
|
||||
const m = Math.floor(s/60), sec = s%60;
|
||||
return `${m}:${String(sec).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function codecTag(codec, type) {
|
||||
if (!codec || codec === 'none') return '';
|
||||
const colors = type === 'v' ? 'bg-blue-900/50 text-blue-300' : 'bg-emerald-900/50 text-emerald-300';
|
||||
return `<span class="tag ${colors}">${codec.split('.')[0]}</span>`;
|
||||
}
|
||||
|
||||
async function fetchInfo() {
|
||||
const url = document.getElementById('urlInput').value.trim();
|
||||
const cookies = document.getElementById('cookiesInput').value.trim();
|
||||
if (!url) return;
|
||||
|
||||
setStatus('Fetching video info…');
|
||||
showTerminal();
|
||||
appendLine('$ yt-dlp --dump-json ' + url);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/info', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({url, cookies})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) { appendLine('[ERROR] ' + data.error, true); setDone(true); return; }
|
||||
|
||||
// Show info card
|
||||
document.getElementById('infoCard').classList.remove('hidden');
|
||||
document.getElementById('videoTitle').textContent = data.title;
|
||||
document.getElementById('videoMeta').textContent =
|
||||
(data.uploader ? data.uploader + ' · ' : '') + formatDuration(data.duration);
|
||||
document.getElementById('formatCount').textContent = data.formats.length + ' formats available';
|
||||
if (data.thumbnail) {
|
||||
const img = document.getElementById('thumbnail');
|
||||
img.src = data.thumbnail; img.classList.remove('hidden');
|
||||
}
|
||||
|
||||
allFormats = data.formats;
|
||||
renderFormats(allFormats);
|
||||
appendLine('[info] Found ' + data.formats.length + ' formats', false, 'text-violet-400');
|
||||
setDone(false);
|
||||
} catch(e) {
|
||||
appendLine('[ERROR] ' + e.message, true);
|
||||
setDone(true);
|
||||
}
|
||||
}
|
||||
|
||||
function filterFormats(type) {
|
||||
currentFilter = type;
|
||||
['all','video','audio'].forEach(t => {
|
||||
const btn = document.getElementById('f' + t.charAt(0).toUpperCase() + t.slice(1));
|
||||
btn.className = t === type
|
||||
? 'text-xs px-3 py-1 rounded-lg bg-violet-600 text-white'
|
||||
: 'text-xs px-3 py-1 rounded-lg glass text-slate-400 hover:text-white';
|
||||
});
|
||||
let filtered = allFormats;
|
||||
if (type === 'video') filtered = allFormats.filter(f => f.vcodec && f.vcodec !== 'none');
|
||||
if (type === 'audio') filtered = allFormats.filter(f => (!f.vcodec || f.vcodec === 'none') && f.acodec && f.acodec !== 'none');
|
||||
renderFormats(filtered);
|
||||
}
|
||||
|
||||
function renderFormats(formats) {
|
||||
const card = document.getElementById('formatsCard');
|
||||
card.classList.remove('hidden');
|
||||
const table = document.getElementById('formatsTable');
|
||||
table.innerHTML = formats.map(f => `
|
||||
<div class="format-row px-6 py-3 cursor-pointer flex items-center gap-4 ${selectedFormat === f.id ? 'selected' : ''}"
|
||||
onclick="selectFormat('${f.id}', this)" data-id="${f.id}">
|
||||
<span class="mono text-violet-400 text-xs w-16 flex-shrink-0">${f.id}</span>
|
||||
<span class="text-sm text-white flex-1 min-w-0 truncate">${f.resolution || f.label || '—'}</span>
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
${codecTag(f.vcodec, 'v')}
|
||||
${codecTag(f.acodec, 'a')}
|
||||
<span class="tag bg-slate-800 text-slate-400">${f.ext || '?'}</span>
|
||||
</div>
|
||||
<span class="mono text-xs text-slate-500 w-16 text-right flex-shrink-0">${formatSize(f.filesize)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectFormat(id, el) {
|
||||
selectedFormat = id;
|
||||
document.querySelectorAll('.format-row').forEach(r => r.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
document.getElementById('selectedLabel').textContent = 'format: ' + id;
|
||||
document.getElementById('dlBtn').disabled = false;
|
||||
}
|
||||
|
||||
function downloadSelected() {
|
||||
if (!selectedFormat) return;
|
||||
startDownload(selectedFormat);
|
||||
}
|
||||
|
||||
function downloadDirect() {
|
||||
startDownload(null);
|
||||
}
|
||||
|
||||
async function startDownload(formatId) {
|
||||
const url = document.getElementById('urlInput').value.trim();
|
||||
const cookies = document.getElementById('cookiesInput').value.trim();
|
||||
const extraArgs = document.getElementById('extraArgs').value.trim();
|
||||
if (!url) return;
|
||||
|
||||
showTerminal();
|
||||
setStatus('Starting download…');
|
||||
|
||||
const res = await fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({url, cookies, format_id: formatId || '', extra_args: extraArgs})
|
||||
});
|
||||
const {job_id, error} = await res.json();
|
||||
if (error) { appendLine('[ERROR] ' + error, true); setDone(true); return; }
|
||||
|
||||
const evts = new EventSource('/api/status/' + job_id);
|
||||
evts.onmessage = e => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.line !== undefined) {
|
||||
appendLine(msg.line);
|
||||
tryParseProgress(msg.line);
|
||||
}
|
||||
if (msg.done) {
|
||||
setDone(msg.error);
|
||||
evts.close();
|
||||
}
|
||||
if (msg.error && !msg.done) { appendLine('[ERROR] Job not found', true); evts.close(); }
|
||||
};
|
||||
}
|
||||
|
||||
function tryParseProgress(line) {
|
||||
// [download] 72.3% of 327.45MiB at 35.20MiB/s
|
||||
const m = line.match(/\[download\]\s+([\d.]+)%/);
|
||||
if (m) {
|
||||
const pct = parseFloat(m[1]);
|
||||
document.getElementById('progressWrap').classList.remove('hidden');
|
||||
document.getElementById('progressBar').style.width = pct + '%';
|
||||
document.getElementById('progressLabel').textContent = pct.toFixed(1) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
function showTerminal() {
|
||||
document.getElementById('terminalCard').classList.remove('hidden');
|
||||
document.getElementById('terminal').innerHTML = '';
|
||||
document.getElementById('termDot').className = 'w-2 h-2 rounded-full bg-yellow-400 pulse-dot';
|
||||
document.getElementById('progressWrap').classList.add('hidden');
|
||||
document.getElementById('progressBar').style.width = '0%';
|
||||
}
|
||||
|
||||
function appendLine(text, isErr = false, extraClass = '') {
|
||||
const term = document.getElementById('terminal');
|
||||
const line = document.createElement('div');
|
||||
line.className = isErr ? 'text-red-400' : ('text-slate-300 ' + extraClass);
|
||||
// Colorize common prefixes
|
||||
const colored = text
|
||||
.replace(/^\[download\]/, '<span class="text-violet-400">[download]</span>')
|
||||
.replace(/^\[info\]/, '<span class="text-sky-400">[info]</span>')
|
||||
.replace(/^\[patreon\]/, '<span class="text-orange-400">[patreon]</span>')
|
||||
.replace(/^\[hlsnative\]/, '<span class="text-teal-400">[hlsnative]</span>')
|
||||
.replace(/^\[FixupM3u8\]/, '<span class="text-yellow-400">[FixupM3u8]</span>')
|
||||
.replace(/^\[ERROR\]/, '<span class="text-red-400">[ERROR]</span>');
|
||||
line.innerHTML = colored;
|
||||
term.appendChild(line);
|
||||
term.scrollTop = term.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
appendLine('# ' + msg, false, 'text-slate-500');
|
||||
}
|
||||
|
||||
function setDone(isErr) {
|
||||
const dot = document.getElementById('termDot');
|
||||
dot.className = 'w-2 h-2 rounded-full ' + (isErr ? 'bg-red-500' : 'bg-emerald-400');
|
||||
if (!isErr) {
|
||||
document.getElementById('progressBar').style.width = '100%';
|
||||
appendLine('✓ Done', false, 'text-emerald-400 font-semibold');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask
|
||||
flask-cors
|
||||
4
urls.json
Normal file
4
urls.json
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"https://www.patreon.com/posts/example-post-12345678",
|
||||
"https://www.patreon.com/posts/another-example-87654321"
|
||||
]
|
||||
154
with_ui.py
Normal file
154
with_ui.py
Normal file
@@ -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/<job_id>')
|
||||
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)
|
||||
Reference in New Issue
Block a user