Initial commit: add core functionality for Patreon media downloading with UI and batch processing support

This commit is contained in:
Space-Banane
2026-04-06 23:00:10 +02:00
commit 75b071c5be
7 changed files with 786 additions and 0 deletions

14
MIT-0.txt Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
flask
flask-cors

4
urls.json Normal file
View 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
View 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)