Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b827f28d | ||
|
|
0b6472b810 | ||
|
|
7ddebce93b | ||
|
|
c9041bde01 | ||
| 069dfc0ae4 |
66
.github/workflows/build-windows.yml
vendored
Normal file
66
.github/workflows/build-windows.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: BatchTube Windows Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version tag (e.g. v1.0.0)'
|
||||||
|
required: false
|
||||||
|
default: 'v1.0.0'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
- name: Download yt-dlp.exe
|
||||||
|
run: |
|
||||||
|
mkdir -p tools
|
||||||
|
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o tools/yt-dlp.exe
|
||||||
|
|
||||||
|
- name: Create empty database
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python -c "import sqlite3; sqlite3.connect('youtube_sipoc.db').close()"
|
||||||
|
|
||||||
|
- name: Build Windows EXE
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pyinstaller batchtube-windows.spec --clean --noconfirm
|
||||||
|
|
||||||
|
- name: Install NSIS
|
||||||
|
run: choco install nsis -y
|
||||||
|
|
||||||
|
- name: Build NSIS Installer
|
||||||
|
run: makensis installer.nsi
|
||||||
|
|
||||||
|
- name: Upload EXE artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: BatchTube-Windows-EXE
|
||||||
|
path: backend/dist/BatchTube.exe
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload Installer artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: BatchTube-Windows-Installer
|
||||||
|
path: BatchTube-Setup-*.exe
|
||||||
|
retention-days: 30
|
||||||
116
WINDOWS_BUILD_README.md
Normal file
116
WINDOWS_BUILD_README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# BatchTube Windows Build Guide
|
||||||
|
Intentures LLC | https://batchtube.com | v1.0.0
|
||||||
|
|
||||||
|
## Files In This Package
|
||||||
|
|
||||||
|
| File | Location | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| batchtube-launcher.py | backend/ | Windows entry point - starts FastAPI + opens browser |
|
||||||
|
| batchtube-windows.spec | backend/ | PyInstaller spec - single-file EXE with all deps |
|
||||||
|
| version_info.txt | backend/ | Windows EXE metadata |
|
||||||
|
| installer.nsi | project root | NSIS installer script |
|
||||||
|
| build_windows.bat | project root | One-click build script |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+ (64-bit) - https://python.org/downloads
|
||||||
|
- NSIS 3.x - https://nsis.sourceforge.io/
|
||||||
|
- Git - https://git-scm.com
|
||||||
|
|
||||||
|
## Quick Build
|
||||||
|
|
||||||
|
Double-click build_windows.bat - it handles everything:
|
||||||
|
1. Creates virtual environment
|
||||||
|
2. Installs all Python dependencies
|
||||||
|
3. Downloads yt-dlp.exe
|
||||||
|
4. Runs PyInstaller -> backend/dist/BatchTube.exe
|
||||||
|
5. Runs NSIS -> BatchTube-Setup-1.0.0.exe
|
||||||
|
|
||||||
|
## Manual Build Steps
|
||||||
|
|
||||||
|
### Step 1 - Get Source
|
||||||
|
|
||||||
|
scp -r root@212.28.188.26:/root/batchtube-tauri ./batchtube
|
||||||
|
cd batchtube
|
||||||
|
|
||||||
|
### Step 2 - Fix config.py DB Path
|
||||||
|
|
||||||
|
Edit backend/config.py - replace the DB_PATH line:
|
||||||
|
|
||||||
|
# Before:
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "youtube_sipoc.db")
|
||||||
|
|
||||||
|
# After:
|
||||||
|
DB_PATH = os.environ.get(
|
||||||
|
'BATCHTUBE_DB_PATH',
|
||||||
|
os.path.join(os.path.dirname(__file__), "youtube_sipoc.db")
|
||||||
|
)
|
||||||
|
|
||||||
|
### Step 3 - Install Dependencies
|
||||||
|
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
### Step 4 - Download yt-dlp
|
||||||
|
|
||||||
|
mkdir ..\tools
|
||||||
|
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o ..\tools\yt-dlp.exe
|
||||||
|
|
||||||
|
### Step 5 - Build EXE
|
||||||
|
|
||||||
|
pyinstaller batchtube-windows.spec --clean
|
||||||
|
# Output: backend/dist/BatchTube.exe
|
||||||
|
|
||||||
|
### Step 6 - Test EXE
|
||||||
|
|
||||||
|
dist\BatchTube.exe
|
||||||
|
# Browser should open to http://127.0.0.1:8765
|
||||||
|
|
||||||
|
### Step 7 - Build Installer
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
makensis installer.nsi
|
||||||
|
# Output: BatchTube-Setup-1.0.0.exe
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| ModuleNotFoundError at runtime | Add to hiddenimports in batchtube-windows.spec |
|
||||||
|
| Web UI 404 | Check datas path ('web','backend/web') in spec |
|
||||||
|
| DB crash on launch | Apply config.py DB_PATH fix in Step 2 |
|
||||||
|
| App closes instantly | Set console=True in spec temporarily to see errors |
|
||||||
|
| SmartScreen warning | Code-sign BatchTube.exe with a Windows cert |
|
||||||
|
|
||||||
|
## After Install
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| C:\Program Files\BatchTube\BatchTube.exe | Application |
|
||||||
|
| %APPDATA%\BatchTube\batchtube.db | SQLite database |
|
||||||
|
| Start Menu -> BatchTube | Launch shortcut |
|
||||||
|
| Desktop | Launch shortcut |
|
||||||
|
|
||||||
|
## GitHub Actions - Automated Windows Build
|
||||||
|
|
||||||
|
Add to .github/workflows/build.yml:
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with: { python-version: '3.11' }
|
||||||
|
- run: pip install -r backend/requirements.txt pyinstaller
|
||||||
|
- run: cd backend && pyinstaller batchtube-windows.spec --clean
|
||||||
|
- run: choco install nsis -y && makensis installer.nsi
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: BatchTube-Windows
|
||||||
|
path: BatchTube-Setup-*.exe
|
||||||
|
|
||||||
|
---
|
||||||
|
Generated 2026-03-07 by Agent Developer - Intentures LLC
|
||||||
@@ -1,443 +1,477 @@
|
|||||||
"""
|
"""
|
||||||
api_server.py - FastAPI backend for BatchTube
|
api_server.py - FastAPI backend for BatchTube
|
||||||
Serves the Web GUI, REST API, and Server-Sent Events (SSE) for real-time progress.
|
Serves the Web GUI, REST API, and Server-Sent Events (SSE) for real-time progress.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Ensure project directory is always on sys.path (needed for some Python installations)
|
# Ensure project directory is always on sys.path (needed for some Python installations)
|
||||||
_PROJECT_DIR = Path(__file__).parent.resolve()
|
_PROJECT_DIR = Path(__file__).parent.resolve()
|
||||||
if str(_PROJECT_DIR) not in sys.path:
|
if str(_PROJECT_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(_PROJECT_DIR))
|
sys.path.insert(0, str(_PROJECT_DIR))
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
import database as db
|
import database as db
|
||||||
import worker
|
import worker
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("api_server")
|
logger = logging.getLogger("api_server")
|
||||||
|
|
||||||
app = FastAPI(title="BatchTube", version="1.0.0")
|
app = FastAPI(title="BatchTube", version="1.0.0")
|
||||||
|
|
||||||
# CORS - allow localhost GUI access
|
# CORS - allow localhost GUI access
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Serve static web files
|
# Serve static web files
|
||||||
WEB_DIR = Path(__file__).parent / "web"
|
WEB_DIR = Path(__file__).parent / "web"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Request / Response Models
|
# Request / Response Models
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class CreateJobRequest(BaseModel):
|
class CreateJobRequest(BaseModel):
|
||||||
input_type: str # url | playlist | csv | handle
|
input_type: str # url | playlist | csv | handle
|
||||||
input_value: str # the URL/handle/CSV text
|
input_value: str # the URL/handle/CSV text
|
||||||
output_folder: str # absolute path to save files
|
output_folder: str # absolute path to save files
|
||||||
selections: List[str] # artifact types to extract
|
selections: List[str] # artifact types to extract
|
||||||
resolution: str = "best" # for MP4
|
resolution: str = "best" # for MP4
|
||||||
delay_min: float = 2.0
|
delay_min: float = 2.0
|
||||||
delay_max: float = 7.0
|
delay_max: float = 7.0
|
||||||
openrouter_api_key: str = ""
|
openrouter_api_key: str = ""
|
||||||
openrouter_model: str = "openai/gpt-4o-mini"
|
openrouter_model: str = "openai/gpt-4o-mini"
|
||||||
summary_prompt: str = ""
|
summary_prompt: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ResumeJobRequest(BaseModel):
|
class ResumeJobRequest(BaseModel):
|
||||||
openrouter_api_key: str = ""
|
openrouter_api_key: str = ""
|
||||||
|
|
||||||
|
|
||||||
class BrowseFolderRequest(BaseModel):
|
class BrowseFolderRequest(BaseModel):
|
||||||
path: str = ""
|
path: str = ""
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Web GUI Serving
|
# Web GUI Serving
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def serve_gui():
|
async def serve_gui():
|
||||||
index_path = WEB_DIR / "index.html"
|
index_path = WEB_DIR / "index.html"
|
||||||
if not index_path.exists():
|
if not index_path.exists():
|
||||||
return HTMLResponse("<h1>Web GUI not found. Check the web/ directory.</h1>", status_code=404)
|
return HTMLResponse("<h1>Web GUI not found. Check the web/ directory.</h1>", status_code=404)
|
||||||
return HTMLResponse(index_path.read_text(encoding="utf-8"))
|
return HTMLResponse(index_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/app.js")
|
@app.get("/app.js")
|
||||||
async def serve_js():
|
async def serve_js():
|
||||||
return FileResponse(WEB_DIR / "app.js", media_type="application/javascript")
|
return FileResponse(WEB_DIR / "app.js", media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/style.css")
|
@app.get("/style.css")
|
||||||
async def serve_css():
|
async def serve_css():
|
||||||
return FileResponse(WEB_DIR / "style.css", media_type="text/css")
|
return FileResponse(WEB_DIR / "style.css", media_type="text/css")
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Job Management API
|
# Job Management API
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.post("/api/jobs")
|
@app.post("/api/jobs")
|
||||||
async def create_job(req: CreateJobRequest):
|
async def create_job(req: CreateJobRequest):
|
||||||
"""Create and immediately launch a new extraction job."""
|
"""Create and immediately launch a new extraction job."""
|
||||||
# Validate output folder
|
# Validate output folder
|
||||||
output_folder = req.output_folder.strip()
|
output_folder = req.output_folder.strip()
|
||||||
if not output_folder:
|
if not output_folder:
|
||||||
raise HTTPException(status_code=400, detail="output_folder is required")
|
raise HTTPException(status_code=400, detail="output_folder is required")
|
||||||
|
|
||||||
# Validate selections
|
# Validate selections
|
||||||
valid_types = set(config.ARTIFACT_TYPES)
|
valid_types = set(config.ARTIFACT_TYPES)
|
||||||
invalid = [s for s in req.selections if s not in valid_types]
|
invalid = [s for s in req.selections if s not in valid_types]
|
||||||
if invalid:
|
if invalid:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid artifact types: {invalid}")
|
raise HTTPException(status_code=400, detail=f"Invalid artifact types: {invalid}")
|
||||||
if not req.selections:
|
if not req.selections:
|
||||||
raise HTTPException(status_code=400, detail="At least one artifact type must be selected")
|
raise HTTPException(status_code=400, detail="At least one artifact type must be selected")
|
||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
if not req.input_value.strip():
|
if not req.input_value.strip():
|
||||||
raise HTTPException(status_code=400, detail="input_value is required")
|
raise HTTPException(status_code=400, detail="input_value is required")
|
||||||
|
|
||||||
# Create the folder if it doesn't exist
|
|
||||||
try:
|
# ── Free Tier Enforcement ─────────────────────────────────────────────────
|
||||||
Path(output_folder).mkdir(parents=True, exist_ok=True)
|
plan = config.BATCHTUBE_PLAN
|
||||||
except Exception as e:
|
limits = config.PLAN_LIMITS.get(plan, config.PLAN_LIMITS["free"])
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot create output folder: {e}")
|
|
||||||
|
# Cap resolution for free tier
|
||||||
# Create job in DB
|
resolution = req.resolution
|
||||||
job_id = db.create_job(
|
max_res = limits.get("max_resolution")
|
||||||
output_folder=output_folder,
|
if max_res and max_res != "best":
|
||||||
selections=req.selections,
|
res_order = config.AVAILABLE_RESOLUTIONS # ["best", "4320", ..., "144"]
|
||||||
resolution=req.resolution,
|
# Lower index = higher quality; cap if requested res is higher than allowed
|
||||||
delay_min=req.delay_min,
|
def res_index(r):
|
||||||
delay_max=req.delay_max,
|
try:
|
||||||
input_type=req.input_type,
|
return res_order.index(r)
|
||||||
input_value=req.input_value,
|
except ValueError:
|
||||||
summary_prompt=req.summary_prompt or config.SUMMARY_SYSTEM_PROMPT,
|
return len(res_order)
|
||||||
openrouter_model=req.openrouter_model or config.OPENROUTER_MODEL,
|
if res_index(resolution) < res_index(max_res):
|
||||||
)
|
logger.info(f"Free tier: capping resolution from {resolution} to {max_res}")
|
||||||
|
resolution = max_res
|
||||||
# Launch background worker thread
|
else:
|
||||||
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
|
resolution = req.resolution
|
||||||
|
|
||||||
logger.info(f"Created and launched job {job_id}")
|
# For CSV input: enforce max_videos limit upfront
|
||||||
return {"job_id": job_id, "status": "running"}
|
max_videos = limits.get("max_videos")
|
||||||
|
if max_videos is not None and req.input_type == "csv":
|
||||||
|
csv_lines = [ln.strip() for ln in req.input_value.strip().splitlines() if ln.strip()]
|
||||||
@app.get("/api/jobs")
|
if len(csv_lines) > max_videos:
|
||||||
async def list_jobs():
|
raise HTTPException(
|
||||||
"""List all jobs (most recent first)."""
|
status_code=403,
|
||||||
jobs = db.list_jobs()
|
detail=f"Free tier is limited to {max_videos} videos per batch. "
|
||||||
result = []
|
f"Upgrade to Creator or Pro for larger batches. "
|
||||||
for j in jobs:
|
f"({len(csv_lines)} URLs submitted)"
|
||||||
stats = db.get_job_stats(j["job_id"])
|
)
|
||||||
j.update(stats)
|
|
||||||
result.append(j)
|
# Create the folder if it doesn't exist
|
||||||
return result
|
try:
|
||||||
|
Path(output_folder).mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
@app.get("/api/jobs/{job_id}")
|
raise HTTPException(status_code=400, detail=f"Cannot create output folder: {e}")
|
||||||
async def get_job(job_id: str):
|
|
||||||
"""Get job details + stats."""
|
# Create job in DB
|
||||||
job = db.get_job(job_id)
|
job_id = db.create_job(
|
||||||
if not job:
|
output_folder=output_folder,
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
selections=req.selections,
|
||||||
stats = db.get_job_stats(job_id)
|
resolution=resolution, # may be capped by free tier enforcement
|
||||||
job.update(stats)
|
delay_min=req.delay_min,
|
||||||
return job
|
delay_max=req.delay_max,
|
||||||
|
input_type=req.input_type,
|
||||||
|
input_value=req.input_value,
|
||||||
@app.post("/api/jobs/{job_id}/pause")
|
summary_prompt=req.summary_prompt or config.SUMMARY_SYSTEM_PROMPT,
|
||||||
async def pause_job(job_id: str):
|
openrouter_model=req.openrouter_model or config.OPENROUTER_MODEL,
|
||||||
job = db.get_job(job_id)
|
)
|
||||||
if not job:
|
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
# Launch background worker thread
|
||||||
if job["status"] not in ("running",):
|
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot pause a job with status: {job['status']}")
|
|
||||||
worker.pause_job(job_id)
|
logger.info(f"Created and launched job {job_id}")
|
||||||
return {"job_id": job_id, "status": "paused"}
|
return {"job_id": job_id, "status": "running"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/jobs/{job_id}/resume")
|
@app.get("/api/jobs")
|
||||||
async def resume_job(job_id: str, req: ResumeJobRequest = ResumeJobRequest()):
|
async def list_jobs():
|
||||||
job = db.get_job(job_id)
|
"""List all jobs (most recent first)."""
|
||||||
if not job:
|
jobs = db.list_jobs()
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
result = []
|
||||||
|
for j in jobs:
|
||||||
if job["status"] == "paused":
|
stats = db.get_job_stats(j["job_id"])
|
||||||
# Job thread is still alive, just unpause it
|
j.update(stats)
|
||||||
worker.resume_job(job_id)
|
result.append(j)
|
||||||
return {"job_id": job_id, "status": "running"}
|
return result
|
||||||
|
|
||||||
elif job["status"] in ("stopped", "failed", "done_with_errors"):
|
|
||||||
# Re-launch the job from where it left off
|
@app.get("/api/jobs/{job_id}")
|
||||||
db.update_job_status(job_id, "pending")
|
async def get_job(job_id: str):
|
||||||
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
|
"""Get job details + stats."""
|
||||||
return {"job_id": job_id, "status": "running", "note": "Job re-launched to resume from last checkpoint"}
|
job = db.get_job(job_id)
|
||||||
|
if not job:
|
||||||
else:
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot resume job with status: {job['status']}")
|
stats = db.get_job_stats(job_id)
|
||||||
|
job.update(stats)
|
||||||
|
return job
|
||||||
@app.post("/api/jobs/{job_id}/stop")
|
|
||||||
async def stop_job(job_id: str):
|
|
||||||
job = db.get_job(job_id)
|
@app.post("/api/jobs/{job_id}/pause")
|
||||||
if not job:
|
async def pause_job(job_id: str):
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
job = db.get_job(job_id)
|
||||||
worker.stop_job(job_id)
|
if not job:
|
||||||
return {"job_id": job_id, "status": "stopped"}
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
if job["status"] not in ("running",):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Cannot pause a job with status: {job['status']}")
|
||||||
@app.post("/api/jobs/{job_id}/skip")
|
worker.pause_job(job_id)
|
||||||
async def skip_video(job_id: str):
|
return {"job_id": job_id, "status": "paused"}
|
||||||
"""Skip the currently processing video."""
|
|
||||||
job = db.get_job(job_id)
|
|
||||||
if not job:
|
@app.post("/api/jobs/{job_id}/resume")
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
async def resume_job(job_id: str, req: ResumeJobRequest = ResumeJobRequest()):
|
||||||
worker.skip_video(job_id)
|
job = db.get_job(job_id)
|
||||||
return {"job_id": job_id, "action": "skip_sent"}
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
if job["status"] == "paused":
|
||||||
# Videos & Artifacts
|
# Job thread is still alive, just unpause it
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
worker.resume_job(job_id)
|
||||||
|
return {"job_id": job_id, "status": "running"}
|
||||||
@app.get("/api/jobs/{job_id}/videos")
|
|
||||||
async def get_videos(job_id: str):
|
elif job["status"] in ("stopped", "failed", "done_with_errors"):
|
||||||
videos = db.get_videos(job_id)
|
# Re-launch the job from where it left off
|
||||||
if not videos and not db.get_job(job_id):
|
db.update_job_status(job_id, "pending")
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
|
||||||
# Enrich with artifact statuses
|
return {"job_id": job_id, "status": "running", "note": "Job re-launched to resume from last checkpoint"}
|
||||||
result = []
|
|
||||||
for v in videos:
|
else:
|
||||||
arts = db.get_artifacts_for_video(job_id, v["video_id"])
|
raise HTTPException(status_code=400, detail=f"Cannot resume job with status: {job['status']}")
|
||||||
v["artifacts"] = arts
|
|
||||||
result.append(v)
|
|
||||||
return result
|
@app.post("/api/jobs/{job_id}/stop")
|
||||||
|
async def stop_job(job_id: str):
|
||||||
|
job = db.get_job(job_id)
|
||||||
@app.get("/api/jobs/{job_id}/artifacts")
|
if not job:
|
||||||
async def get_artifacts(job_id: str):
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
return db.get_all_artifacts(job_id)
|
worker.stop_job(job_id)
|
||||||
|
return {"job_id": job_id, "status": "stopped"}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Logging
|
@app.post("/api/jobs/{job_id}/skip")
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
async def skip_video(job_id: str):
|
||||||
|
"""Skip the currently processing video."""
|
||||||
@app.get("/api/jobs/{job_id}/logs")
|
job = db.get_job(job_id)
|
||||||
async def get_logs(job_id: str, limit: int = 500):
|
if not job:
|
||||||
if not db.get_job(job_id):
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
worker.skip_video(job_id)
|
||||||
return db.get_logs(job_id, limit=limit)
|
return {"job_id": job_id, "action": "skip_sent"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}/logs/csv")
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
async def download_log_csv(job_id: str):
|
# Videos & Artifacts
|
||||||
"""Download artifact log as CSV."""
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
if not db.get_job(job_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
@app.get("/api/jobs/{job_id}/videos")
|
||||||
csv_content = db.get_log_csv(job_id)
|
async def get_videos(job_id: str):
|
||||||
return StreamingResponse(
|
videos = db.get_videos(job_id)
|
||||||
iter([csv_content]),
|
if not videos and not db.get_job(job_id):
|
||||||
media_type="text/csv",
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
headers={"Content-Disposition": f'attachment; filename="job_{job_id[:8]}_log.csv"'},
|
# Enrich with artifact statuses
|
||||||
)
|
result = []
|
||||||
|
for v in videos:
|
||||||
|
arts = db.get_artifacts_for_video(job_id, v["video_id"])
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
v["artifacts"] = arts
|
||||||
# Server-Sent Events (SSE) for real-time updates
|
result.append(v)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
return result
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}/events")
|
|
||||||
async def job_events(job_id: str, request: Request):
|
@app.get("/api/jobs/{job_id}/artifacts")
|
||||||
"""
|
async def get_artifacts(job_id: str):
|
||||||
SSE endpoint. Client subscribes and receives real-time events
|
return db.get_all_artifacts(job_id)
|
||||||
as the worker processes artifacts.
|
|
||||||
"""
|
|
||||||
if not db.get_job(job_id):
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
# Logging
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
queue = worker.emitter.subscribe(job_id)
|
|
||||||
|
@app.get("/api/jobs/{job_id}/logs")
|
||||||
async def event_generator():
|
async def get_logs(job_id: str, limit: int = 500):
|
||||||
try:
|
if not db.get_job(job_id):
|
||||||
# Send initial state snapshot
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
job = db.get_job(job_id)
|
return db.get_logs(job_id, limit=limit)
|
||||||
stats = db.get_job_stats(job_id)
|
|
||||||
if job:
|
|
||||||
job.update(stats)
|
@app.get("/api/jobs/{job_id}/logs/csv")
|
||||||
yield _sse("job_status", {"job_id": job_id, "status": job["status"], "stats": stats})
|
async def download_log_csv(job_id: str):
|
||||||
|
"""Download artifact log as CSV."""
|
||||||
while True:
|
if not db.get_job(job_id):
|
||||||
if await request.is_disconnected():
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
break
|
csv_content = db.get_log_csv(job_id)
|
||||||
|
return StreamingResponse(
|
||||||
# Poll the queue with a short timeout
|
iter([csv_content]),
|
||||||
event = await asyncio.get_event_loop().run_in_executor(
|
media_type="text/csv",
|
||||||
None, lambda: queue.get(timeout=1.0)
|
headers={"Content-Disposition": f'attachment; filename="job_{job_id[:8]}_log.csv"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
if event:
|
|
||||||
yield _sse(event["type"], event["data"])
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
else:
|
# Server-Sent Events (SSE) for real-time updates
|
||||||
# Send a keepalive comment
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
yield ": keepalive\n\n"
|
|
||||||
|
@app.get("/api/jobs/{job_id}/events")
|
||||||
# Check if job is complete
|
async def job_events(job_id: str, request: Request):
|
||||||
job = db.get_job(job_id)
|
"""
|
||||||
if job and job["status"] in ("done", "done_with_errors", "stopped", "failed"):
|
SSE endpoint. Client subscribes and receives real-time events
|
||||||
# Give a moment for final events to flush
|
as the worker processes artifacts.
|
||||||
await asyncio.sleep(0.2)
|
"""
|
||||||
# Drain remaining queue events
|
if not db.get_job(job_id):
|
||||||
while True:
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
event = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
None, lambda: queue.get(timeout=0.1)
|
queue = worker.emitter.subscribe(job_id)
|
||||||
)
|
|
||||||
if event:
|
async def event_generator():
|
||||||
yield _sse(event["type"], event["data"])
|
try:
|
||||||
else:
|
# Send initial state snapshot
|
||||||
break
|
job = db.get_job(job_id)
|
||||||
yield _sse("stream_end", {"job_id": job_id})
|
stats = db.get_job_stats(job_id)
|
||||||
break
|
if job:
|
||||||
|
job.update(stats)
|
||||||
except asyncio.CancelledError:
|
yield _sse("job_status", {"job_id": job_id, "status": job["status"], "stats": stats})
|
||||||
pass
|
|
||||||
finally:
|
while True:
|
||||||
worker.emitter.unsubscribe(job_id, queue)
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
return StreamingResponse(
|
|
||||||
event_generator(),
|
# Poll the queue with a short timeout
|
||||||
media_type="text/event-stream",
|
event = await asyncio.get_event_loop().run_in_executor(
|
||||||
headers={
|
None, lambda: queue.get(timeout=1.0)
|
||||||
"Cache-Control": "no-cache",
|
)
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
"Connection": "keep-alive",
|
if event:
|
||||||
},
|
yield _sse(event["type"], event["data"])
|
||||||
)
|
else:
|
||||||
|
# Send a keepalive comment
|
||||||
|
yield ": keepalive\n\n"
|
||||||
def _sse(event_type: str, data: dict) -> str:
|
|
||||||
"""Format a Server-Sent Event string."""
|
# Check if job is complete
|
||||||
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
job = db.get_job(job_id)
|
||||||
|
if job and job["status"] in ("done", "done_with_errors", "stopped", "failed"):
|
||||||
|
# Give a moment for final events to flush
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
await asyncio.sleep(0.2)
|
||||||
# File System Browsing Helper
|
# Drain remaining queue events
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
while True:
|
||||||
|
event = await asyncio.get_event_loop().run_in_executor(
|
||||||
@app.get("/api/browse")
|
None, lambda: queue.get(timeout=0.1)
|
||||||
async def browse_folder(path: str = ""):
|
)
|
||||||
"""
|
if event:
|
||||||
Return directory listing for folder picking in the GUI.
|
yield _sse(event["type"], event["data"])
|
||||||
Defaults to user home directory.
|
else:
|
||||||
"""
|
break
|
||||||
if not path:
|
yield _sse("stream_end", {"job_id": job_id})
|
||||||
path = str(Path.home())
|
break
|
||||||
|
|
||||||
target = Path(path)
|
except asyncio.CancelledError:
|
||||||
if not target.exists() or not target.is_dir():
|
pass
|
||||||
# Fallback to home
|
finally:
|
||||||
target = Path.home()
|
worker.emitter.unsubscribe(job_id, queue)
|
||||||
|
|
||||||
try:
|
return StreamingResponse(
|
||||||
entries = []
|
event_generator(),
|
||||||
for entry in sorted(target.iterdir()):
|
media_type="text/event-stream",
|
||||||
try:
|
headers={
|
||||||
entries.append({
|
"Cache-Control": "no-cache",
|
||||||
"name": entry.name,
|
"X-Accel-Buffering": "no",
|
||||||
"path": str(entry.absolute()),
|
"Connection": "keep-alive",
|
||||||
"is_dir": entry.is_dir(),
|
},
|
||||||
})
|
)
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
|
def _sse(event_type: str, data: dict) -> str:
|
||||||
# Add parent directory option
|
"""Format a Server-Sent Event string."""
|
||||||
parent = str(target.parent.absolute())
|
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
||||||
return {
|
|
||||||
"current": str(target.absolute()),
|
|
||||||
"parent": parent if parent != str(target.absolute()) else None,
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
"entries": entries,
|
# File System Browsing Helper
|
||||||
}
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied")
|
@app.get("/api/browse")
|
||||||
|
async def browse_folder(path: str = ""):
|
||||||
|
"""
|
||||||
@app.post("/api/mkdir")
|
Return directory listing for folder picking in the GUI.
|
||||||
async def make_dir(req: BrowseFolderRequest):
|
Defaults to user home directory.
|
||||||
"""Create a new directory."""
|
"""
|
||||||
if not req.path:
|
if not path:
|
||||||
raise HTTPException(status_code=400, detail="path is required")
|
path = str(Path.home())
|
||||||
try:
|
|
||||||
Path(req.path).mkdir(parents=True, exist_ok=True)
|
target = Path(path)
|
||||||
return {"created": req.path}
|
if not target.exists() or not target.is_dir():
|
||||||
except Exception as e:
|
# Fallback to home
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
target = Path.home()
|
||||||
|
|
||||||
|
try:
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
entries = []
|
||||||
# Config / Settings API
|
for entry in sorted(target.iterdir()):
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
try:
|
||||||
|
entries.append({
|
||||||
@app.get("/api/config")
|
"name": entry.name,
|
||||||
async def get_config():
|
"path": str(entry.absolute()),
|
||||||
"""Return non-sensitive defaults for the GUI."""
|
"is_dir": entry.is_dir(),
|
||||||
return {
|
})
|
||||||
"available_resolutions": config.AVAILABLE_RESOLUTIONS,
|
except PermissionError:
|
||||||
"artifact_types": config.ARTIFACT_TYPES,
|
pass
|
||||||
"default_resolution": config.DEFAULT_VIDEO_RESOLUTION,
|
|
||||||
"delay_min": config.DELAY_MIN_SECONDS,
|
# Add parent directory option
|
||||||
"delay_max": config.DELAY_MAX_SECONDS,
|
parent = str(target.parent.absolute())
|
||||||
"max_retries": config.MAX_RETRIES,
|
return {
|
||||||
"default_model": config.OPENROUTER_MODEL,
|
"current": str(target.absolute()),
|
||||||
"summary_prompt": config.SUMMARY_SYSTEM_PROMPT,
|
"parent": parent if parent != str(target.absolute()) else None,
|
||||||
"openrouter_base_url": config.OPENROUTER_BASE_URL,
|
"entries": entries,
|
||||||
}
|
}
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=403, detail="Permission denied")
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Health Check
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
@app.post("/api/mkdir")
|
||||||
|
async def make_dir(req: BrowseFolderRequest):
|
||||||
@app.get("/api/health")
|
"""Create a new directory."""
|
||||||
async def health():
|
if not req.path:
|
||||||
return {"status": "ok", "version": "1.0.0"}
|
raise HTTPException(status_code=400, detail="path is required")
|
||||||
|
try:
|
||||||
|
Path(req.path).mkdir(parents=True, exist_ok=True)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
return {"created": req.path}
|
||||||
# Entry Point
|
except Exception as e:
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print(f"\n{'='*60}")
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
print(" BatchTube")
|
# Config / Settings API
|
||||||
print(f" Web GUI: http://{config.API_HOST}:{config.API_PORT}")
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
print(f" API: http://{config.API_HOST}:{config.API_PORT}/api")
|
|
||||||
print(f"{'='*60}\n")
|
@app.get("/api/config")
|
||||||
uvicorn.run(
|
async def get_config():
|
||||||
"api_server:app",
|
"""Return non-sensitive defaults for the GUI."""
|
||||||
host=config.API_HOST,
|
return {
|
||||||
port=config.API_PORT,
|
"available_resolutions": config.AVAILABLE_RESOLUTIONS,
|
||||||
reload=False,
|
"artifact_types": config.ARTIFACT_TYPES,
|
||||||
log_level="info",
|
"default_resolution": config.DEFAULT_VIDEO_RESOLUTION,
|
||||||
)
|
"delay_min": config.DELAY_MIN_SECONDS,
|
||||||
|
"delay_max": config.DELAY_MAX_SECONDS,
|
||||||
|
"max_retries": config.MAX_RETRIES,
|
||||||
|
"default_model": config.OPENROUTER_MODEL,
|
||||||
|
"summary_prompt": config.SUMMARY_SYSTEM_PROMPT,
|
||||||
|
"openrouter_base_url": config.OPENROUTER_BASE_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Health Check
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Entry Point
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(" BatchTube")
|
||||||
|
print(f" Web GUI: http://{config.API_HOST}:{config.API_PORT}")
|
||||||
|
print(f" API: http://{config.API_HOST}:{config.API_PORT}/api")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
uvicorn.run(
|
||||||
|
"api_server:app",
|
||||||
|
host=config.API_HOST,
|
||||||
|
port=config.API_PORT,
|
||||||
|
reload=False,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|||||||
34
backend/batchtube-launcher.py
Normal file
34
backend/batchtube-launcher.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import sys, os, time, socket, threading, webbrowser
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
BASE_DIR = sys._MEIPASS
|
||||||
|
APP_DIR = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
APP_DIR = BASE_DIR
|
||||||
|
BACKEND_DIR = os.path.join(BASE_DIR,'backend') if os.path.isdir(os.path.join(BASE_DIR,'backend')) else BASE_DIR
|
||||||
|
sys.path.insert(0, BACKEND_DIR)
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
APPDATA_DIR = os.path.join(os.environ.get('APPDATA', APP_DIR), 'BatchTube')
|
||||||
|
os.makedirs(APPDATA_DIR, exist_ok=True)
|
||||||
|
os.environ['BATCHTUBE_DB_PATH'] = os.path.join(APPDATA_DIR, 'batchtube.db')
|
||||||
|
for c in [os.path.join(APP_DIR,'yt-dlp.exe'), os.path.join(BASE_DIR,'yt-dlp.exe')]:
|
||||||
|
if os.path.exists(c): os.environ['YTDLP_PATH'] = c; break
|
||||||
|
for d in [os.path.join(APP_DIR,'ffmpeg'), os.path.join(BASE_DIR,'ffmpeg')]:
|
||||||
|
if os.path.isdir(d): os.environ['PATH'] = d + os.pathsep + os.environ.get('PATH',''); break
|
||||||
|
HOST, PORT = '127.0.0.1', 8765
|
||||||
|
URL = f'http://{HOST}:{PORT}'
|
||||||
|
def is_open(h, p, t=0.5):
|
||||||
|
try:
|
||||||
|
with socket.create_connection((h,p), timeout=t): return True
|
||||||
|
except: return False
|
||||||
|
def open_browser():
|
||||||
|
for _ in range(60):
|
||||||
|
if is_open(HOST, PORT): time.sleep(0.3); webbrowser.open(URL); return
|
||||||
|
time.sleep(0.5)
|
||||||
|
def run_server():
|
||||||
|
import uvicorn
|
||||||
|
os.chdir(BACKEND_DIR)
|
||||||
|
uvicorn.run('api_server:app', host=HOST, port=PORT, log_level='warning', access_log=False)
|
||||||
|
if __name__ == '__main__':
|
||||||
|
threading.Thread(target=open_browser, daemon=True).start()
|
||||||
|
run_server()
|
||||||
54
backend/batchtube-windows.spec
Normal file
54
backend/batchtube-windows.spec
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# batchtube-windows.spec
|
||||||
|
# PyInstaller spec for Windows single-file EXE
|
||||||
|
# Run from backend/ directory: pyinstaller batchtube-windows.spec --clean
|
||||||
|
# Output: dist/BatchTube.exe
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
block_cipher = None
|
||||||
|
datas, binaries, hiddenimports = [], [], []
|
||||||
|
for pkg in ['yt_dlp','uvicorn','fastapi','starlette','anyio','sse_starlette','youtube_transcript_api','openai','pydantic']:
|
||||||
|
d,b,h = collect_all(pkg)
|
||||||
|
datas+=d; binaries+=b; hiddenimports+=h
|
||||||
|
datas += [('web','backend/web')]
|
||||||
|
hiddenimports += [
|
||||||
|
'uvicorn.logging','uvicorn.loops','uvicorn.loops.auto','uvicorn.loops.asyncio',
|
||||||
|
'uvicorn.protocols','uvicorn.protocols.http','uvicorn.protocols.http.auto',
|
||||||
|
'uvicorn.protocols.http.h11_impl','uvicorn.protocols.http.httptools_impl',
|
||||||
|
'uvicorn.protocols.websockets','uvicorn.protocols.websockets.auto',
|
||||||
|
'uvicorn.lifespan','uvicorn.lifespan.off','uvicorn.lifespan.on',
|
||||||
|
'fastapi','fastapi.middleware.cors','starlette.middleware.cors',
|
||||||
|
'sse_starlette','sse_starlette.sse',
|
||||||
|
'pydantic','pydantic.v1','pydantic_core','annotated_types','typing_extensions',
|
||||||
|
'h11','httptools','httpx','anyio','anyio._backends._asyncio','sniffio',
|
||||||
|
'multipart','python_multipart','aiofiles','aiofiles.os','aiofiles.threadpool',
|
||||||
|
'yt_dlp','yt_dlp.extractor','yt_dlp.downloader','yt_dlp.postprocessor',
|
||||||
|
'youtube_transcript_api','youtube_transcript_api._transcripts',
|
||||||
|
'openai','openai.resources',
|
||||||
|
'requests','urllib3','certifi','charset_normalizer',
|
||||||
|
'slugify','python_slugify','tqdm','tqdm.auto',
|
||||||
|
'sqlite3','_sqlite3','asyncio','concurrent.futures','threading','queue',
|
||||||
|
'websockets','click','rich','watchfiles','mcp',
|
||||||
|
'api_server','worker','database','extractors','config',
|
||||||
|
]
|
||||||
|
a = Analysis(
|
||||||
|
['batchtube-launcher.py'],
|
||||||
|
pathex=['.'],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=['tkinter','matplotlib','numpy','pandas','torch','tensorflow','PyQt5','PyQt6'],
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
exe = EXE(
|
||||||
|
pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [],
|
||||||
|
name='BatchTube',
|
||||||
|
debug=False, strip=False, upx=True,
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
icon='../src-tauri/icons/icon.ico',
|
||||||
|
version='version_info.txt',
|
||||||
|
)
|
||||||
@@ -1,38 +1,71 @@
|
|||||||
"""
|
"""
|
||||||
config.py - Default configuration for YouTube SIPOC Tool
|
config.py - Default configuration for BatchTube
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# ── API Settings ──────────────────────────────────────────────────────────────
|
# ── API Settings ──────────────────────────────────────────────────────────────
|
||||||
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
||||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
OPENROUTER_MODEL = os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o-mini")
|
OPENROUTER_MODEL = os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o-mini")
|
||||||
|
|
||||||
# ── Server Settings ───────────────────────────────────────────────────────────
|
# ── Server Settings ───────────────────────────────────────────────────────────
|
||||||
API_HOST = "127.0.0.1"
|
API_HOST = "127.0.0.1"
|
||||||
API_PORT = 8765
|
API_PORT = 8765
|
||||||
|
|
||||||
# ── Download / Bot Avoidance ──────────────────────────────────────────────────
|
# ── Download / Bot Avoidance ──────────────────────────────────────────────────
|
||||||
DELAY_MIN_SECONDS = 2.0 # minimum random pause between artifacts
|
DELAY_MIN_SECONDS = 2.0 # minimum random pause between artifacts
|
||||||
DELAY_MAX_SECONDS = 7.0 # maximum random pause between artifacts
|
DELAY_MAX_SECONDS = 7.0 # maximum random pause between artifacts
|
||||||
MAX_RETRIES = 3 # max retry attempts per failed artifact
|
MAX_RETRIES = 3 # max retry attempts per failed artifact
|
||||||
|
|
||||||
# ── Video Download ────────────────────────────────────────────────────────────
|
# ── Video Download ────────────────────────────────────────────────────────────
|
||||||
DEFAULT_VIDEO_RESOLUTION = "1080" # used if not specified by user
|
DEFAULT_VIDEO_RESOLUTION = "1080" # used if not specified by user
|
||||||
AVAILABLE_RESOLUTIONS = ["best", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
|
AVAILABLE_RESOLUTIONS = ["best", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
|
||||||
|
|
||||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
SUMMARY_SYSTEM_PROMPT = (
|
SUMMARY_SYSTEM_PROMPT = (
|
||||||
"You are a helpful assistant that creates concise, well-structured summaries of video transcripts. "
|
"You are a helpful assistant that creates concise, well-structured summaries of video transcripts. "
|
||||||
"Include key points, main topics, and any actionable insights. Format with bullet points where appropriate."
|
"Include key points, main topics, and any actionable insights. Format with bullet points where appropriate."
|
||||||
)
|
)
|
||||||
SUMMARY_MAX_TOKENS = 1024
|
SUMMARY_MAX_TOKENS = 1024
|
||||||
|
|
||||||
# ── File Naming ───────────────────────────────────────────────────────────────
|
# ── File Naming ───────────────────────────────────────────────────────────────
|
||||||
MAX_TITLE_LENGTH = 80 # characters - truncate long titles in filenames
|
MAX_TITLE_LENGTH = 80 # characters - truncate long titles in filenames
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
DB_PATH = os.path.join(os.path.dirname(__file__), "youtube_sipoc.db")
|
# Windows-safe: allow override via env var (avoids path issues in PyInstaller bundles)
|
||||||
|
DB_PATH = os.environ.get(
|
||||||
# ── Artifact Types ────────────────────────────────────────────────────────────
|
"BATCHTUBE_DB_PATH",
|
||||||
ARTIFACT_TYPES = ["title", "stats", "transcript", "summary", "thumbnail", "mp3", "mp4"]
|
os.path.join(os.path.dirname(__file__), "youtube_sipoc.db")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Artifact Types ────────────────────────────────────────────────────────────
|
||||||
|
ARTIFACT_TYPES = ["title", "stats", "transcript", "summary", "thumbnail", "mp3", "mp4"]
|
||||||
|
|
||||||
|
# ── Licensing / Plan Tiers ────────────────────────────────────────────────────
|
||||||
|
# Plan is set via environment variable BATCHTUBE_PLAN.
|
||||||
|
# Valid values: free | creator | pro | studio | lifetime
|
||||||
|
# Defaults to 'free' — license validation layer overrides this at runtime.
|
||||||
|
BATCHTUBE_PLAN = os.environ.get("BATCHTUBE_PLAN", "free").lower()
|
||||||
|
|
||||||
|
# ── Free Tier Limits (updated 2026-03-07) ─────────────────────────────────────
|
||||||
|
# Free tier: 3 videos/batch max (tightened from 5), 720p max resolution
|
||||||
|
FREE_TIER_MAX_VIDEOS = 3 # max videos per batch job on free plan
|
||||||
|
FREE_TIER_MAX_RESOLUTION = "720" # max resolution on free plan (was 1080p)
|
||||||
|
|
||||||
|
# ── Plan Limits Map ───────────────────────────────────────────────────────────
|
||||||
|
# Maps plan name -> {max_videos, max_resolution}; None = unlimited
|
||||||
|
PLAN_LIMITS = {
|
||||||
|
"free": {"max_videos": FREE_TIER_MAX_VIDEOS, "max_resolution": FREE_TIER_MAX_RESOLUTION},
|
||||||
|
"creator": {"max_videos": 100, "max_resolution": "1080"},
|
||||||
|
"pro": {"max_videos": None, "max_resolution": "best"},
|
||||||
|
"studio": {"max_videos": None, "max_resolution": "best"},
|
||||||
|
"lifetime": {"max_videos": None, "max_resolution": "best"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Pricing Reference (2026-03-07 approved) ───────────────────────────────────
|
||||||
|
# Free: 3 videos/batch, 720p max
|
||||||
|
# Creator: $19/mo or $149/yr — 100 videos/batch, 1080p
|
||||||
|
# Pro: $39/mo or $299/yr — unlimited, 4K
|
||||||
|
# Studio: $79/mo — 3 seats, API access
|
||||||
|
# Lifetime: $197 one-time — unlimited, all features
|
||||||
|
# AppSumo: $79 one-time — limited launch deal
|
||||||
|
|||||||
16
backend/version_info.txt
Normal file
16
backend/version_info.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(filevers=(1,0,0,0),prodvers=(1,0,0,0),mask=0x3f,flags=0x0,OS=0x40004,fileType=0x1,subtype=0x0,date=(0,0)),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo([StringTable(u'040904B0',[
|
||||||
|
StringStruct(u'CompanyName',u'Intentures LLC'),
|
||||||
|
StringStruct(u'FileDescription',u'BatchTube - Batch YouTube Content Extractor'),
|
||||||
|
StringStruct(u'FileVersion',u'1.0.0.0'),
|
||||||
|
StringStruct(u'InternalName',u'BatchTube'),
|
||||||
|
StringStruct(u'LegalCopyright',u'Copyright (C) 2026 Intentures LLC'),
|
||||||
|
StringStruct(u'OriginalFilename',u'BatchTube.exe'),
|
||||||
|
StringStruct(u'ProductName',u'BatchTube'),
|
||||||
|
StringStruct(u'ProductVersion',u'1.0.0.0'),
|
||||||
|
])]),
|
||||||
|
VarFileInfo([VarStruct(u'Translation',[1033,1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
1035
backend/worker.py
1035
backend/worker.py
File diff suppressed because it is too large
Load Diff
45
build_windows.bat
Normal file
45
build_windows.bat
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@echo off
|
||||||
|
title BatchTube Windows Build
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo BatchTube Windows Installer Builder v1.0.0
|
||||||
|
echo Intentures LLC - https://batchtube.com
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
python --version >nul 2>&1
|
||||||
|
if errorlevel 1 (echo ERROR: Python not found. Install Python 3.11+ && pause && exit /b 1)
|
||||||
|
|
||||||
|
echo [1/5] Creating virtual environment...
|
||||||
|
if exist build_venv rmdir /s /q build_venv
|
||||||
|
python -m venv build_venv
|
||||||
|
call build_venv\Scripts\activate.bat
|
||||||
|
|
||||||
|
echo [2/5] Installing dependencies...
|
||||||
|
pip install --upgrade pip -q
|
||||||
|
pip install -r backend\requirements.txt -q
|
||||||
|
pip install pyinstaller -q
|
||||||
|
|
||||||
|
echo [3/5] Downloading yt-dlp...
|
||||||
|
if not exist tools mkdir tools
|
||||||
|
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o tools\yt-dlp.exe
|
||||||
|
|
||||||
|
echo [4/5] Building with PyInstaller...
|
||||||
|
cd backend
|
||||||
|
pyinstaller batchtube-windows.spec --clean --noconfirm
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
if not exist backend\dist\BatchTube.exe (echo ERROR: Build failed && pause && exit /b 1)
|
||||||
|
echo Build successful: backend\dist\BatchTube.exe
|
||||||
|
|
||||||
|
echo [5/5] Creating NSIS installer...
|
||||||
|
makensis /VERSION >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo NSIS not found - install from https://nsis.sourceforge.io/
|
||||||
|
echo Your EXE is ready at: backend\dist\BatchTube.exe
|
||||||
|
) else (
|
||||||
|
makensis installer.nsi
|
||||||
|
echo.
|
||||||
|
echo SUCCESS: BatchTube-Setup-1.0.0.exe created
|
||||||
|
)
|
||||||
|
pause
|
||||||
11
deploy-web.sh
Executable file
11
deploy-web.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# BatchTube Landing Page Deploy Script
|
||||||
|
# Run on server: bash deploy-web.sh
|
||||||
|
set -e
|
||||||
|
SRC_DIR="$(dirname "$0")/web"
|
||||||
|
DEST_DIR="/opt/www/batchtube"
|
||||||
|
echo "Deploying BatchTube landing page..."
|
||||||
|
cp "$SRC_DIR/index.html" "$DEST_DIR/index.html"
|
||||||
|
cp "$SRC_DIR/affiliates.html" "$DEST_DIR/affiliates.html"
|
||||||
|
echo "✅ Deploy complete: $DEST_DIR"
|
||||||
|
ls -lh "$DEST_DIR"
|
||||||
61
installer.nsi
Normal file
61
installer.nsi
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
; BatchTube NSIS Installer Script - Updated for backend/ structure
|
||||||
|
; Requires NSIS 3.x - https://nsis.sourceforge.io/
|
||||||
|
|
||||||
|
!define APP_NAME "BatchTube"
|
||||||
|
!define APP_VERSION "1.0.0"
|
||||||
|
!define APP_PUBLISHER "Intentures LLC"
|
||||||
|
!define APP_URL "https://batchtube.com"
|
||||||
|
!define APP_EXE "BatchTube.exe"
|
||||||
|
!define INSTALL_DIR "$PROGRAMFILES64\BatchTube"
|
||||||
|
|
||||||
|
Name "${APP_NAME} ${APP_VERSION}"
|
||||||
|
OutFile "BatchTube-Setup-${APP_VERSION}.exe"
|
||||||
|
InstallDir "${INSTALL_DIR}"
|
||||||
|
InstallDirRegKey HKLM "Software\${APP_NAME}" "Install_Dir"
|
||||||
|
RequestExecutionLevel admin
|
||||||
|
|
||||||
|
!include "MUI2.nsh"
|
||||||
|
!define MUI_ABORTWARNING
|
||||||
|
!define MUI_ICON "..\src-tauri\icons\icon.ico"
|
||||||
|
!define MUI_UNICON "..\src-tauri\icons\icon.ico"
|
||||||
|
|
||||||
|
!insertmacro MUI_PAGE_WELCOME
|
||||||
|
!insertmacro MUI_PAGE_LICENSE "..\LICENSE.txt"
|
||||||
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
|
!define MUI_FINISHPAGE_RUN "$INSTDIR\${APP_EXE}"
|
||||||
|
!define MUI_FINISHPAGE_RUN_TEXT "Launch BatchTube now"
|
||||||
|
!insertmacro MUI_PAGE_FINISH
|
||||||
|
!insertmacro MUI_UNPAGE_CONFIRM
|
||||||
|
!insertmacro MUI_UNPAGE_INSTFILES
|
||||||
|
!insertmacro MUI_LANGUAGE "English"
|
||||||
|
|
||||||
|
Section "BatchTube" SecMain
|
||||||
|
SetOutPath "$INSTDIR"
|
||||||
|
File "backend\dist\BatchTube.exe"
|
||||||
|
; Optional: bundle yt-dlp
|
||||||
|
; File "tools\yt-dlp.exe"
|
||||||
|
; Optional: bundle ffmpeg
|
||||||
|
; File /r "tools\ffmpeg"
|
||||||
|
WriteRegStr HKLM "Software\${APP_NAME}" "Install_Dir" "$INSTDIR"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayName" "${APP_NAME}"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "UninstallString" '"$INSTDIR\Uninstall.exe"'
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayVersion" "${APP_VERSION}"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "Publisher" "${APP_PUBLISHER}"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "URLInfoAbout" "${APP_URL}"
|
||||||
|
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "NoModify" 1
|
||||||
|
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "NoRepair" 1
|
||||||
|
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||||
|
CreateShortcut "$DESKTOP\BatchTube.lnk" "$INSTDIR\${APP_EXE}"
|
||||||
|
CreateDirectory "$SMPROGRAMS\BatchTube"
|
||||||
|
CreateShortcut "$SMPROGRAMS\BatchTube\BatchTube.lnk" "$INSTDIR\${APP_EXE}"
|
||||||
|
CreateShortcut "$SMPROGRAMS\BatchTube\Uninstall BatchTube.lnk" "$INSTDIR\Uninstall.exe"
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "Uninstall"
|
||||||
|
RMDir /r "$INSTDIR"
|
||||||
|
Delete "$DESKTOP\BatchTube.lnk"
|
||||||
|
RMDir /r "$SMPROGRAMS\BatchTube"
|
||||||
|
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"
|
||||||
|
DeleteRegKey HKLM "Software\${APP_NAME}"
|
||||||
|
SectionEnd
|
||||||
359
web/affiliates.html
Normal file
359
web/affiliates.html
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BatchTube Affiliate Program — Earn 30% Commission</title>
|
||||||
|
<meta name="description" content="Join the BatchTube affiliate program and earn 30% commission on every sale — monthly, annual, and lifetime plans. Managed via ThriveCart." />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--orange: #ff8822;
|
||||||
|
--orange2: #cc5500;
|
||||||
|
--dark: #0f0f0f;
|
||||||
|
--dark2: #1a1a1a;
|
||||||
|
--dark3: #242424;
|
||||||
|
--border: #2e2e2e;
|
||||||
|
--text: #f0f0f0;
|
||||||
|
--text-dim: #aaa;
|
||||||
|
--radius: 10px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--dark); color: var(--text); line-height: 1.6; }
|
||||||
|
a { color: var(--orange); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* NAV */
|
||||||
|
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 40px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: rgba(15,15,15,0.95); backdrop-filter: blur(8px); z-index: 100; }
|
||||||
|
.nav-logo { font-size: 22px; font-weight: 800; color: var(--text); }
|
||||||
|
.nav-logo span { color: var(--orange); }
|
||||||
|
.nav-links { display: flex; gap: 28px; font-size: 14px; }
|
||||||
|
.nav-links a { color: var(--text-dim); transition: color 0.2s; }
|
||||||
|
.nav-links a:hover, .nav-links a.active { color: var(--text); text-decoration: none; }
|
||||||
|
.nav-cta { background: var(--orange); color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 700; transition: background 0.2s; }
|
||||||
|
.nav-cta:hover { background: var(--orange2); text-decoration: none; }
|
||||||
|
|
||||||
|
/* HERO */
|
||||||
|
.aff-hero { text-align: center; padding: 80px 20px 60px; background: linear-gradient(180deg, rgba(255,136,34,0.06) 0%, transparent 100%); }
|
||||||
|
.aff-badge { display: inline-block; background: rgba(255,136,34,0.15); border: 1px solid rgba(255,136,34,0.3); color: var(--orange); font-size: 12px; font-weight: 700; padding: 6px 16px; border-radius: 20px; margin-bottom: 24px; letter-spacing: 1px; text-transform: uppercase; }
|
||||||
|
.aff-hero h1 { font-size: clamp(32px, 5vw, 60px); font-weight: 800; margin-bottom: 20px; line-height: 1.1; }
|
||||||
|
.aff-hero h1 span { color: var(--orange); }
|
||||||
|
.aff-hero p { font-size: 18px; color: var(--text-dim); max-width: 600px; margin: 0 auto 40px; }
|
||||||
|
.btn-primary { display: inline-block; background: var(--orange); color: #fff; padding: 16px 36px; border-radius: 10px; font-size: 16px; font-weight: 700; transition: background 0.2s; }
|
||||||
|
.btn-primary:hover { background: var(--orange2); text-decoration: none; }
|
||||||
|
.btn-outline { display: inline-block; border: 2px solid var(--border); color: var(--text); padding: 14px 32px; border-radius: 10px; font-size: 15px; font-weight: 600; transition: all 0.2s; margin-left: 12px; }
|
||||||
|
.btn-outline:hover { border-color: var(--orange); color: var(--orange); text-decoration: none; }
|
||||||
|
|
||||||
|
/* STATS BAR */
|
||||||
|
.stats-bar { display: flex; justify-content: center; gap: 60px; padding: 40px 20px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--dark2); flex-wrap: wrap; }
|
||||||
|
.stat-item { text-align: center; }
|
||||||
|
.stat-num { font-size: 36px; font-weight: 800; color: var(--orange); }
|
||||||
|
.stat-label { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* SECTION */
|
||||||
|
.section { max-width: 1100px; margin: 0 auto; padding: 80px 20px; }
|
||||||
|
.section-label { font-size: 12px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--orange); margin-bottom: 12px; }
|
||||||
|
.section-title { font-size: clamp(26px, 3.5vw, 40px); font-weight: 800; margin-bottom: 16px; }
|
||||||
|
.section-sub { font-size: 17px; color: var(--text-dim); max-width: 600px; margin-bottom: 48px; }
|
||||||
|
|
||||||
|
/* HOW IT WORKS */
|
||||||
|
.steps-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 24px; }
|
||||||
|
.step-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 28px; }
|
||||||
|
.step-num { width: 40px; height: 40px; background: var(--orange); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 800; margin-bottom: 16px; }
|
||||||
|
.step-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.step-desc { font-size: 14px; color: var(--text-dim); line-height: 1.6; }
|
||||||
|
|
||||||
|
/* COMMISSION TABLE */
|
||||||
|
.commission-table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
|
||||||
|
.commission-table th { background: var(--dark3); padding: 14px 20px; text-align: left; font-size: 13px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-dim); border-bottom: 1px solid var(--border); }
|
||||||
|
.commission-table td { padding: 16px 20px; border-bottom: 1px solid var(--border); font-size: 15px; }
|
||||||
|
.commission-table tr:hover td { background: rgba(255,136,34,0.03); }
|
||||||
|
.commission-table .earn { color: var(--orange); font-weight: 700; font-size: 16px; }
|
||||||
|
.commission-table .featured-row td { background: rgba(255,136,34,0.05); }
|
||||||
|
|
||||||
|
/* CALCULATOR */
|
||||||
|
.calculator { background: var(--dark2); border: 1px solid var(--orange); border-radius: var(--radius); padding: 40px; max-width: 700px; margin: 0 auto; }
|
||||||
|
.calculator h3 { font-size: 22px; font-weight: 800; margin-bottom: 8px; }
|
||||||
|
.calculator p { color: var(--text-dim); font-size: 14px; margin-bottom: 28px; }
|
||||||
|
.calc-row { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
|
.calc-label { font-size: 14px; font-weight: 600; min-width: 160px; }
|
||||||
|
.calc-input { background: var(--dark3); border: 1px solid var(--border); color: var(--text); padding: 10px 16px; border-radius: 8px; font-size: 16px; width: 120px; }
|
||||||
|
.calc-input:focus { outline: none; border-color: var(--orange); }
|
||||||
|
select.calc-input { width: 180px; cursor: pointer; }
|
||||||
|
.calc-result { background: linear-gradient(135deg, rgba(255,136,34,0.15), rgba(255,136,34,0.05)); border: 1px solid rgba(255,136,34,0.3); border-radius: var(--radius); padding: 24px 32px; text-align: center; margin-top: 24px; }
|
||||||
|
.calc-result .monthly-earn { font-size: 42px; font-weight: 800; color: var(--orange); }
|
||||||
|
.calc-result .annual-earn { font-size: 20px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
.calc-result .label { font-size: 13px; color: var(--text-dim); margin-top: 8px; }
|
||||||
|
|
||||||
|
/* PAYOUTS */
|
||||||
|
.payout-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
||||||
|
.payout-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; text-align: center; }
|
||||||
|
.payout-icon { font-size: 32px; margin-bottom: 12px; }
|
||||||
|
.payout-title { font-size: 15px; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.payout-desc { font-size: 13px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* CTA */
|
||||||
|
.aff-cta { text-align: center; padding: 80px 20px; background: linear-gradient(180deg, transparent, rgba(255,136,34,0.06)); }
|
||||||
|
.aff-cta h2 { font-size: clamp(28px, 4vw, 44px); font-weight: 800; margin-bottom: 16px; }
|
||||||
|
.aff-cta p { font-size: 17px; color: var(--text-dim); max-width: 520px; margin: 0 auto 36px; }
|
||||||
|
|
||||||
|
/* FOOTER */
|
||||||
|
footer { text-align: center; padding: 40px 20px; border-top: 1px solid var(--border); color: var(--text-dim); font-size: 14px; }
|
||||||
|
.footer-logo { font-size: 20px; font-weight: 700; color: var(--text); margin-bottom: 12px; }
|
||||||
|
.footer-logo span { color: var(--orange); }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
nav { padding: 14px 20px; }
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.stats-bar { gap: 30px; }
|
||||||
|
.calc-row { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- NAV -->
|
||||||
|
<nav>
|
||||||
|
<div class="nav-logo">Batch<span>Tube</span></div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#pricing">Pricing</a>
|
||||||
|
<a href="/affiliates.html" class="active">Affiliates</a>
|
||||||
|
<a href="/#faq">FAQ</a>
|
||||||
|
</div>
|
||||||
|
<a href="https://qmg.thrivecart.com/affiliate/request-access/batchtube/" class="nav-cta" target="_blank">Join Now →</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="aff-hero">
|
||||||
|
<div class="aff-badge">💰 Affiliate Program</div>
|
||||||
|
<h1>Earn <span>30% Commission</span><br>On Every BatchTube Sale</h1>
|
||||||
|
<p>Monthly subscriptions. Annual plans. Lifetime deals. You refer once — we pay you every renewal. No cap. No expiry.</p>
|
||||||
|
<a href="https://qmg.thrivecart.com/affiliate/request-access/batchtube/" class="btn-primary" target="_blank">Apply to Become an Affiliate →</a>
|
||||||
|
<a href="#calculator" class="btn-outline">Calculate Your Earnings</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STATS BAR -->
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-num">30%</div>
|
||||||
|
<div class="stat-label">Commission Rate</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-num">$59</div>
|
||||||
|
<div class="stat-label">Max Per Lifetime Sale</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-num">30 days</div>
|
||||||
|
<div class="stat-label">Cookie Duration</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-num">Instant</div>
|
||||||
|
<div class="stat-label">ThriveCart Tracking</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-num">Monthly</div>
|
||||||
|
<div class="stat-label">Payout Schedule</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HOW IT WORKS -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-label">How It Works</div>
|
||||||
|
<h2 class="section-title">Simple. Transparent. Profitable.</h2>
|
||||||
|
<p class="section-sub">Four steps from signup to your first commission check.</p>
|
||||||
|
<div class="steps-grid">
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div class="step-title">Apply</div>
|
||||||
|
<div class="step-desc">Submit your affiliate application via ThriveCart. Approval is fast — usually within 24 hours.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div class="step-title">Get Your Link</div>
|
||||||
|
<div class="step-desc">Receive your unique tracking link. Share it on YouTube, blogs, newsletters, social media — anywhere your audience lives.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div class="step-title">Refer & Earn</div>
|
||||||
|
<div class="step-desc">Every time someone buys through your link, you earn 30% commission. Monthly plans pay every renewal cycle.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-num">4</div>
|
||||||
|
<div class="step-title">Get Paid</div>
|
||||||
|
<div class="step-desc">Commissions paid monthly via PayPal or bank transfer through ThriveCart's affiliate payout system.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- COMMISSION TABLE -->
|
||||||
|
<section class="section" style="padding-top:0;">
|
||||||
|
<div class="section-label">Commission Breakdown</div>
|
||||||
|
<h2 class="section-title">What You Earn Per Sale</h2>
|
||||||
|
<p class="section-sub">30% on every plan — monthly, annual, and lifetime.</p>
|
||||||
|
<table class="commission-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Your Commission</th>
|
||||||
|
<th>Annual Earnings (10 sales/mo)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Creator Monthly</strong></td>
|
||||||
|
<td>$19/mo</td>
|
||||||
|
<td class="earn">$5.70/mo per customer</td>
|
||||||
|
<td>$684/yr</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Creator Annual</strong></td>
|
||||||
|
<td>$190/yr</td>
|
||||||
|
<td class="earn">$57 per sale</td>
|
||||||
|
<td>$570/yr</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="featured-row">
|
||||||
|
<td><strong>Pro Monthly</strong></td>
|
||||||
|
<td>$39/mo</td>
|
||||||
|
<td class="earn">$11.70/mo per customer</td>
|
||||||
|
<td>$1,404/yr</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="featured-row">
|
||||||
|
<td><strong>Pro Annual</strong></td>
|
||||||
|
<td>$790/yr</td>
|
||||||
|
<td class="earn">$237 per sale</td>
|
||||||
|
<td>$2,370/yr</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Studio Monthly</strong></td>
|
||||||
|
<td>$79/mo</td>
|
||||||
|
<td class="earn">$23.70/mo per customer</td>
|
||||||
|
<td>$2,844/yr</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Studio Annual</strong></td>
|
||||||
|
<td>$790/yr</td>
|
||||||
|
<td class="earn">$237 per sale</td>
|
||||||
|
<td>$2,370/yr</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-top: 2px solid var(--orange);">
|
||||||
|
<td><strong>🔥 Lifetime</strong></td>
|
||||||
|
<td>$197 – $247</td>
|
||||||
|
<td class="earn">$59.10 – $74.10 once</td>
|
||||||
|
<td>$591 – $741 (10 sales)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:13px; color:var(--text-dim);">* Monthly plan commissions recur every billing cycle as long as the customer remains subscribed.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CALCULATOR -->
|
||||||
|
<section class="section" id="calculator" style="padding-top:0;">
|
||||||
|
<div class="section-label">Earnings Calculator</div>
|
||||||
|
<h2 class="section-title">How Much Can You Make?</h2>
|
||||||
|
<p class="section-sub">Plug in your numbers. See your potential.</p>
|
||||||
|
<div class="calculator">
|
||||||
|
<h3>🧮 Commission Calculator</h3>
|
||||||
|
<p>Estimate your monthly and annual affiliate earnings</p>
|
||||||
|
<div class="calc-row">
|
||||||
|
<span class="calc-label">Plan type:</span>
|
||||||
|
<select class="calc-input" id="calc-plan" onchange="calcEarnings()">
|
||||||
|
<option value="19">Creator Monthly ($19/mo)</option>
|
||||||
|
<option value="190" data-type="annual">Creator Annual ($190)</option>
|
||||||
|
<option value="39" selected>Pro Monthly ($39/mo)</option>
|
||||||
|
<option value="790" data-type="annual">Pro Annual ($790)</option>
|
||||||
|
<option value="79">Studio Monthly ($79/mo)</option>
|
||||||
|
<option value="790" data-type="annual">Studio Annual ($790)</option>
|
||||||
|
<option value="197" data-type="lifetime">Lifetime ($197)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="calc-row">
|
||||||
|
<span class="calc-label">Referrals per month:</span>
|
||||||
|
<input type="number" class="calc-input" id="calc-refs" value="10" min="1" max="10000" oninput="calcEarnings()" />
|
||||||
|
</div>
|
||||||
|
<div class="calc-result" id="calc-result">
|
||||||
|
<div class="monthly-earn" id="monthly-earn">$117.00</div>
|
||||||
|
<div class="annual-earn" id="annual-earn">$1,404.00 / year</div>
|
||||||
|
<div class="label">Estimated affiliate earnings at 30% commission</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PAYOUT INFO -->
|
||||||
|
<section class="section" style="padding-top:0;">
|
||||||
|
<div class="section-label">Payouts</div>
|
||||||
|
<h2 class="section-title">Getting Paid Is Easy</h2>
|
||||||
|
<p class="section-sub">ThriveCart handles all tracking, attribution, and payouts automatically.</p>
|
||||||
|
<div class="payout-grid">
|
||||||
|
<div class="payout-card">
|
||||||
|
<div class="payout-icon">📅</div>
|
||||||
|
<div class="payout-title">Monthly Payouts</div>
|
||||||
|
<div class="payout-desc">Commissions paid out monthly. No waiting 90 days.</div>
|
||||||
|
</div>
|
||||||
|
<div class="payout-card">
|
||||||
|
<div class="payout-icon">🔗</div>
|
||||||
|
<div class="payout-title">30-Day Cookie</div>
|
||||||
|
<div class="payout-desc">Visitors tracked for 30 days after clicking your link.</div>
|
||||||
|
</div>
|
||||||
|
<div class="payout-card">
|
||||||
|
<div class="payout-icon">📊</div>
|
||||||
|
<div class="payout-title">Real-Time Dashboard</div>
|
||||||
|
<div class="payout-desc">Track clicks, conversions, and earnings live in ThriveCart.</div>
|
||||||
|
</div>
|
||||||
|
<div class="payout-card">
|
||||||
|
<div class="payout-icon">💰</div>
|
||||||
|
<div class="payout-title">Recurring Revenue</div>
|
||||||
|
<div class="payout-desc">Monthly subscribers pay you every single month they stay.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="aff-cta">
|
||||||
|
<h2>Ready to Start Earning?</h2>
|
||||||
|
<p>Join the BatchTube affiliate program today. Free to join. No approval wait. Start earning 30% on day one.</p>
|
||||||
|
<a href="https://qmg.thrivecart.com/affiliate/request-access/batchtube/" class="btn-primary" target="_blank">Apply Now — It's Free →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer>
|
||||||
|
<div class="footer-logo">Batch<span>Tube</span></div>
|
||||||
|
<p style="margin-bottom:12px;">Built by <a href="https://intentures.org">Intentures LLC</a> • <a href="https://mautic.intentures.org/form/1" target="_blank" rel="noopener">Contact Us</a></p>
|
||||||
|
<p>© 2026 Intentures LLC. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function calcEarnings() {
|
||||||
|
const planSelect = document.getElementById('calc-plan');
|
||||||
|
const refs = parseInt(document.getElementById('calc-refs').value) || 0;
|
||||||
|
const price = parseFloat(planSelect.value);
|
||||||
|
const selectedOption = planSelect.options[planSelect.selectedIndex];
|
||||||
|
const type = selectedOption.getAttribute('data-type') || 'monthly';
|
||||||
|
const commission = price * 0.30;
|
||||||
|
|
||||||
|
let monthlyEarn, annualEarn;
|
||||||
|
|
||||||
|
if (type === 'lifetime') {
|
||||||
|
monthlyEarn = commission * refs;
|
||||||
|
annualEarn = monthlyEarn; // one-time
|
||||||
|
document.getElementById('annual-earn').textContent = 'One-time commissions';
|
||||||
|
} else if (type === 'annual') {
|
||||||
|
monthlyEarn = commission * refs;
|
||||||
|
annualEarn = monthlyEarn; // annual sales per month
|
||||||
|
document.getElementById('annual-earn').textContent = '$' + (annualEarn * 12).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' / year';
|
||||||
|
} else {
|
||||||
|
// monthly recurring
|
||||||
|
monthlyEarn = commission * refs;
|
||||||
|
annualEarn = monthlyEarn * 12;
|
||||||
|
document.getElementById('annual-earn').textContent = '$' + annualEarn.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' / year';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('monthly-earn').textContent = '$' + monthlyEarn.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
calcEarnings();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
561
web/index.html
Normal file
561
web/index.html
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<!-- SEO -->
|
||||||
|
<title>BatchTube — Bulk YouTube Downloader | Transcripts, MP3s & AI Summaries</title>
|
||||||
|
<meta name="description" content="BatchTube batch downloads entire YouTube playlists, channels, and bulk URL lists — with transcripts, MP3 extraction, and AI summaries. Runs locally on Windows, Mac, and Linux. No cloud. No data leaks." />
|
||||||
|
<meta name="keywords" content="batch download YouTube, YouTube bulk downloader, YouTube playlist downloader, YouTube transcript extractor, YouTube MP3 extractor, bulk YouTube downloader, YouTube channel downloader" />
|
||||||
|
|
||||||
|
<!-- GEO -->
|
||||||
|
<meta name="geo.region" content="US" />
|
||||||
|
<meta name="geo.placename" content="United States" />
|
||||||
|
<link rel="alternate" hreflang="en-us" href="https://batchtube.com/" />
|
||||||
|
<link rel="alternate" hreflang="en-gb" href="https://batchtube.com/" />
|
||||||
|
<link rel="alternate" hreflang="en-ca" href="https://batchtube.com/" />
|
||||||
|
<link rel="alternate" hreflang="en-au" href="https://batchtube.com/" />
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://batchtube.com/" />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="BatchTube — Bulk YouTube Downloader" />
|
||||||
|
<meta property="og:description" content="Stop downloading one video at a time. BatchTube processes entire playlists, channels, and bulk URL lists with transcripts, MP3s, and AI summaries built in." />
|
||||||
|
<meta property="og:url" content="https://batchtube.com" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:image" content="https://batchtube.com/og-image.png" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="BatchTube — Bulk YouTube Downloader" />
|
||||||
|
<meta name="twitter:description" content="Batch download YouTube videos, playlists, transcripts, MP3s and AI summaries. Runs locally. No cloud." />
|
||||||
|
|
||||||
|
<!-- Schema.org -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "BatchTube",
|
||||||
|
"applicationCategory": "UtilitiesApplication",
|
||||||
|
"operatingSystem": "Windows, macOS, Linux",
|
||||||
|
"offers": [
|
||||||
|
{"@type": "Offer", "name": "Free", "price": "0", "priceCurrency": "USD"},
|
||||||
|
{"@type": "Offer", "name": "Creator", "price": "19", "priceCurrency": "USD"},
|
||||||
|
{"@type": "Offer", "name": "Pro", "price": "39", "priceCurrency": "USD"},
|
||||||
|
{"@type": "Offer", "name": "Studio", "price": "79", "priceCurrency": "USD"},
|
||||||
|
{"@type": "Offer", "name": "Lifetime", "price": "197", "priceCurrency": "USD"}
|
||||||
|
],
|
||||||
|
"description": "BatchTube batch downloads entire YouTube playlists, channels, and bulk URL lists with transcripts, MP3 extraction, and AI summaries.",
|
||||||
|
"url": "https://batchtube.com"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--orange: #ff8822;
|
||||||
|
--orange2: #cc5500;
|
||||||
|
--dark: #0f0f0f;
|
||||||
|
--dark2: #1a1a1a;
|
||||||
|
--dark3: #242424;
|
||||||
|
--border: #2e2e2e;
|
||||||
|
--text: #f0f0f0;
|
||||||
|
--text-dim: #aaa;
|
||||||
|
--radius: 10px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--dark); color: var(--text); line-height: 1.6; }
|
||||||
|
a { color: var(--orange); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* NAV */
|
||||||
|
nav { display: flex; align-items: center; justify-content: space-between; padding: 18px 40px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: rgba(15,15,15,0.95); backdrop-filter: blur(10px); z-index: 100; }
|
||||||
|
.nav-logo { display: flex; align-items: center; gap: 10px; font-size: 22px; font-weight: 700; color: var(--text); }
|
||||||
|
.nav-logo span { color: var(--orange); }
|
||||||
|
.nav-links { display: flex; gap: 28px; font-size: 14px; color: var(--text-dim); }
|
||||||
|
.nav-links a { color: var(--text-dim); }
|
||||||
|
.nav-links a:hover { color: var(--orange); text-decoration: none; }
|
||||||
|
.nav-cta { background: var(--orange); color: #fff; padding: 9px 22px; border-radius: 6px; font-weight: 600; font-size: 14px; }
|
||||||
|
.nav-cta:hover { background: var(--orange2); text-decoration: none; }
|
||||||
|
|
||||||
|
/* HERO */
|
||||||
|
.hero { text-align: center; padding: 100px 20px 80px; max-width: 860px; margin: 0 auto; }
|
||||||
|
.hero-badge { display: inline-block; background: rgba(255,136,34,0.15); color: var(--orange); border: 1px solid rgba(255,136,34,0.3); padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 28px; letter-spacing: 0.5px; }
|
||||||
|
.hero h1 { font-size: clamp(36px, 6vw, 64px); font-weight: 800; line-height: 1.1; margin-bottom: 24px; }
|
||||||
|
.hero h1 span { color: var(--orange); }
|
||||||
|
.hero p { font-size: 20px; color: var(--text-dim); max-width: 640px; margin: 0 auto 40px; }
|
||||||
|
.hero-btns { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.btn-primary { background: var(--orange); color: #fff; padding: 16px 36px; border-radius: 8px; font-size: 17px; font-weight: 700; display: inline-block; transition: background 0.2s; }
|
||||||
|
.btn-primary:hover { background: var(--orange2); text-decoration: none; }
|
||||||
|
.btn-secondary { background: var(--dark3); color: var(--text); padding: 16px 36px; border-radius: 8px; font-size: 17px; font-weight: 600; border: 1px solid var(--border); display: inline-block; }
|
||||||
|
.btn-secondary:hover { border-color: var(--orange); color: var(--orange); text-decoration: none; }
|
||||||
|
.hero-sub { margin-top: 20px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.hero-stats { display: flex; gap: 40px; justify-content: center; margin-top: 48px; flex-wrap: wrap; }
|
||||||
|
.hero-stat { text-align: center; }
|
||||||
|
.hero-stat-num { font-size: 32px; font-weight: 800; color: var(--orange); }
|
||||||
|
.hero-stat-label { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* DEMO */
|
||||||
|
.demo { max-width: 1000px; margin: 0 auto 80px; padding: 0 20px; }
|
||||||
|
.demo-window { background: var(--dark2); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.6); }
|
||||||
|
.demo-bar { background: var(--dark3); padding: 12px 16px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
.demo-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||||
|
.demo-dot.red { background: #ff5f57; }
|
||||||
|
.demo-dot.yellow { background: #febc2e; }
|
||||||
|
.demo-dot.green { background: #28c840; }
|
||||||
|
.demo-title { flex: 1; text-align: center; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.demo-body { padding: 32px; }
|
||||||
|
.demo-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||||
|
.demo-stat-box { background: var(--dark3); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
|
||||||
|
.demo-stat-num { font-size: 28px; font-weight: 800; color: var(--orange); }
|
||||||
|
.demo-stat-label { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
.demo-progress { background: var(--dark3); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
|
||||||
|
.demo-progress-label { font-size: 13px; color: var(--text-dim); margin-bottom: 10px; }
|
||||||
|
.demo-bar-track { background: var(--border); border-radius: 4px; height: 8px; overflow: hidden; }
|
||||||
|
.demo-bar-fill { background: linear-gradient(90deg, var(--orange), #ffaa44); height: 100%; border-radius: 4px; width: 73%; }
|
||||||
|
.demo-items { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.demo-item { display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.demo-item-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--orange); flex-shrink: 0; }
|
||||||
|
.demo-item-dot.done { background: #22cc66; }
|
||||||
|
.demo-item-dot.active { background: var(--orange); animation: pulse 1s infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
|
/* SECTIONS */
|
||||||
|
.section { max-width: 1100px; margin: 0 auto 80px; padding: 0 20px; }
|
||||||
|
.section-label { font-size: 13px; font-weight: 600; color: var(--orange); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 12px; }
|
||||||
|
.section-title { font-size: clamp(28px, 4vw, 42px); font-weight: 800; margin-bottom: 16px; }
|
||||||
|
.section-sub { font-size: 18px; color: var(--text-dim); max-width: 600px; margin-bottom: 48px; }
|
||||||
|
|
||||||
|
/* FEATURES */
|
||||||
|
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px; }
|
||||||
|
.feature-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 28px; transition: border-color 0.2s; }
|
||||||
|
.feature-card:hover { border-color: var(--orange); }
|
||||||
|
.feature-icon { font-size: 28px; margin-bottom: 16px; }
|
||||||
|
.feature-title { font-size: 18px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.feature-desc { font-size: 14px; color: var(--text-dim); line-height: 1.6; }
|
||||||
|
|
||||||
|
/* HOW IT WORKS */
|
||||||
|
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 32px; }
|
||||||
|
.step { text-align: center; }
|
||||||
|
.step-num { width: 48px; height: 48px; border-radius: 50%; background: rgba(255,136,34,0.15); border: 2px solid var(--orange); color: var(--orange); font-size: 20px; font-weight: 800; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; }
|
||||||
|
.step-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.step-desc { font-size: 14px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* MID-PAGE CTA */
|
||||||
|
.mid-cta { background: linear-gradient(135deg, rgba(255,136,34,0.1), rgba(255,136,34,0.05)); border: 1px solid rgba(255,136,34,0.2); border-radius: 16px; padding: 60px 40px; text-align: center; max-width: 800px; margin: 0 auto 80px; }
|
||||||
|
.mid-cta h2 { font-size: clamp(24px, 4vw, 36px); font-weight: 800; margin-bottom: 16px; }
|
||||||
|
.mid-cta p { font-size: 18px; color: var(--text-dim); margin-bottom: 32px; max-width: 560px; margin-left: auto; margin-right: auto; }
|
||||||
|
|
||||||
|
/* TESTIMONIALS */
|
||||||
|
.testimonials-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px; }
|
||||||
|
.testimonial { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 28px; }
|
||||||
|
.testimonial-stars { color: var(--orange); font-size: 18px; margin-bottom: 16px; }
|
||||||
|
.testimonial-text { font-size: 15px; color: var(--text-dim); line-height: 1.7; margin-bottom: 20px; font-style: italic; }
|
||||||
|
.testimonial-author { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.testimonial-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--orange); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px; color: #fff; flex-shrink: 0; }
|
||||||
|
.testimonial-name { font-weight: 700; font-size: 14px; }
|
||||||
|
.testimonial-role { font-size: 12px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* PRICING */
|
||||||
|
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; align-items: start; }
|
||||||
|
.pricing-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; position: relative; }
|
||||||
|
.pricing-card.featured { border-color: var(--orange); background: rgba(255,136,34,0.04); }
|
||||||
|
.pricing-badge { display: inline-block; background: var(--orange); color: #fff; font-size: 11px; font-weight: 700; padding: 4px 10px; border-radius: 4px; margin-bottom: 16px; letter-spacing: 0.5px; text-transform: uppercase; }
|
||||||
|
.pricing-name { font-size: 20px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.pricing-price { font-size: 42px; font-weight: 800; margin-bottom: 4px; }
|
||||||
|
.pricing-price span { font-size: 16px; font-weight: 400; color: var(--text-dim); }
|
||||||
|
.pricing-desc { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
|
||||||
|
.pricing-features { list-style: none; margin-bottom: 28px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.pricing-features li { font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.pricing-features li::before { content: "✓"; color: var(--orange); font-weight: 700; flex-shrink: 0; }
|
||||||
|
.pricing-features li.dim { color: var(--text-dim); }
|
||||||
|
.pricing-features li.dim::before { content: "–"; color: var(--border); }
|
||||||
|
.btn-plan { display: block; text-align: center; padding: 14px 24px; border-radius: 8px; font-size: 15px; font-weight: 700; transition: all 0.2s; }
|
||||||
|
.btn-plan.primary { background: var(--orange); color: #fff; }
|
||||||
|
.btn-plan.primary:hover { background: var(--orange2); text-decoration: none; }
|
||||||
|
.btn-plan.outline { border: 2px solid var(--border); color: var(--text); }
|
||||||
|
.btn-plan.outline:hover { border-color: var(--orange); color: var(--orange); text-decoration: none; }
|
||||||
|
.pricing-guarantee { text-align: center; margin-top: 32px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* FAQ */
|
||||||
|
.faq { max-width: 760px; margin: 0 auto; }
|
||||||
|
.faq-item { border-bottom: 1px solid var(--border); padding: 24px 0; }
|
||||||
|
.faq-q { font-size: 17px; font-weight: 700; margin-bottom: 12px; }
|
||||||
|
.faq-a { font-size: 15px; color: var(--text-dim); line-height: 1.7; }
|
||||||
|
|
||||||
|
/* BOTTOM CTA */
|
||||||
|
.bottom-cta { text-align: center; padding: 80px 20px; background: linear-gradient(180deg, transparent, rgba(255,136,34,0.05)); }
|
||||||
|
.bottom-cta h2 { font-size: clamp(28px, 4vw, 44px); font-weight: 800; margin-bottom: 16px; }
|
||||||
|
.bottom-cta p { font-size: 18px; color: var(--text-dim); margin-bottom: 40px; max-width: 560px; margin-left: auto; margin-right: auto; }
|
||||||
|
|
||||||
|
/* FOOTER */
|
||||||
|
footer { text-align: center; padding: 40px 20px; border-top: 1px solid var(--border); color: var(--text-dim); font-size: 14px; }
|
||||||
|
.footer-logo { font-size: 20px; font-weight: 700; color: var(--text); margin-bottom: 12px; }
|
||||||
|
.footer-logo span { color: var(--orange); }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
nav { padding: 14px 20px; }
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.demo-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.hero-stats { gap: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-annual { font-size: 13px; color: var(--text-dim); margin-top: -2px; margin-bottom: 20px; }
|
||||||
|
.pricing-annual strong { color: var(--orange); }
|
||||||
|
.scarcity-box { background: rgba(255,60,0,0.1); border: 1px solid rgba(255,60,0,0.35); border-radius: 8px; padding: 12px 16px; margin-bottom: 18px; font-size: 13px; line-height: 1.5; }
|
||||||
|
.scarcity-box strong { color: #ff4422; display: block; font-size: 14px; margin-bottom: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- NAV -->
|
||||||
|
<nav>
|
||||||
|
<div class="nav-logo">Batch<span>Tube</span></div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#how-it-works">How It Works</a>
|
||||||
|
<a href="#pricing">Pricing</a>
|
||||||
|
<a href="#walkthrough">Demo</a>
|
||||||
|
<a href="#faq">FAQ</a>
|
||||||
|
<a href="/affiliates.html">Affiliates</a>
|
||||||
|
</div>
|
||||||
|
<a href="#pricing" class="nav-cta">Get BatchTube Free</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-badge">DESKTOP APP • WINDOWS • MAC • LINUX</div>
|
||||||
|
<h1>Stop Downloading YouTube Videos<br><span>One at a Time.</span></h1>
|
||||||
|
<p>BatchTube processes entire playlists, channels, and bulk URL lists — with transcripts, MP3 extraction, and AI summaries built in. Runs locally. No cloud. No data leaks.</p>
|
||||||
|
<div class="hero-btns">
|
||||||
|
<a href="#pricing" class="btn-primary">Download BatchTube Free →</a>
|
||||||
|
<a href="#features" class="btn-secondary">See Features</a>
|
||||||
|
</div>
|
||||||
|
<p class="hero-sub">Free forever • No account required • Start in 60 seconds</p>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div class="hero-stat"><div class="hero-stat-num">500+</div><div class="hero-stat-label">Videos per batch</div></div>
|
||||||
|
<div class="hero-stat"><div class="hero-stat-num">6</div><div class="hero-stat-label">Output formats</div></div>
|
||||||
|
<div class="hero-stat"><div class="hero-stat-num">3</div><div class="hero-stat-label">Platforms supported</div></div>
|
||||||
|
<div class="hero-stat"><div class="hero-stat-num">0</div><div class="hero-stat-label">Cloud dependencies</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- DEMO WINDOW -->
|
||||||
|
<div class="demo">
|
||||||
|
<div class="demo-window">
|
||||||
|
<div class="demo-bar">
|
||||||
|
<div class="demo-dot red"></div>
|
||||||
|
<div class="demo-dot yellow"></div>
|
||||||
|
<div class="demo-dot green"></div>
|
||||||
|
<div class="demo-title">BatchTube — localhost:8000</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-body" style="padding: 0; overflow: hidden;">
|
||||||
|
<img src="/batchtube-demo.gif" alt="BatchTube app walkthrough — batch download, queue management, settings" style="width:100%; display:block; border-radius: 0 0 12px 12px;" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FEATURES -->
|
||||||
|
<section class="section" id="features">
|
||||||
|
<div class="section-label">Features</div>
|
||||||
|
<h2 class="section-title">Everything your YouTube workflow needs.</h2>
|
||||||
|
<p class="section-sub">Six tools in one. All running locally on your machine.</p>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<div class="feature-title">Batch Processing</div>
|
||||||
|
<div class="feature-desc">Paste a playlist URL, a channel URL, or a CSV of hundreds of links. BatchTube queues and processes everything in one run — no babysitting required.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📝</div>
|
||||||
|
<div class="feature-title">Transcript Extraction</div>
|
||||||
|
<div class="feature-desc">Powered by Whisper — OpenAI's speech recognition model running locally. Timestamped, accurate, exportable. Every word from every video, ready to repurpose.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🧠</div>
|
||||||
|
<div class="feature-title">AI Summaries</div>
|
||||||
|
<div class="feature-desc">Get the key points from any video without watching a second of footage. AI summaries extract topics, takeaways, and action items automatically.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<div class="feature-title">Metadata Export</div>
|
||||||
|
<div class="feature-desc">Export structured JSON and CSV — titles, descriptions, view counts, upload dates, channel data. Plug directly into your research or analytics workflow.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📷</div>
|
||||||
|
<div class="feature-title">Real-Time Progress</div>
|
||||||
|
<div class="feature-desc">Live WebSocket dashboard shows every download as it happens. Queue status, completion rates, error logs — full visibility into your batch at all times.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔗</div>
|
||||||
|
<div class="feature-title">MCP Integration</div>
|
||||||
|
<div class="feature-desc">BatchTube ships with a built-in MCP server. Connect it directly to Claude, Cursor, or any AI assistant that supports the Model Context Protocol.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOW IT WORKS -->
|
||||||
|
<section class="section" id="how-it-works">
|
||||||
|
<div class="section-label">How It Works</div>
|
||||||
|
<h2 class="section-title">Running in 60 seconds.</h2>
|
||||||
|
<p class="section-sub">No cloud account. No API keys. No configuration headaches.</p>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div class="step-title">Download & Run</div>
|
||||||
|
<div class="step-desc">Download BatchTube and double-click start.bat (Windows) or run the shell script. Your browser opens automatically.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div class="step-title">Paste Your URLs</div>
|
||||||
|
<div class="step-desc">Paste a YouTube playlist, channel URL, or upload a CSV of hundreds of video links. BatchTube handles the rest.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div class="step-title">Choose Your Output</div>
|
||||||
|
<div class="step-desc">Select video quality, enable transcript extraction, AI summaries, MP3 audio, or metadata export. Mix and match.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">4</div>
|
||||||
|
<div class="step-title">Watch It Run</div>
|
||||||
|
<div class="step-desc">The live dashboard tracks every download in real time. Walk away. Come back to a completed batch — files on your hard drive, yours to keep.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- WALKTHROUGH -->
|
||||||
|
<section class="section" id="walkthrough">
|
||||||
|
<div class="section-label">See It In Action</div>
|
||||||
|
<h2 class="section-title">The full workflow. 60 seconds.</h2>
|
||||||
|
<p class="section-sub">Real app. Real interface. No demo smoke and mirrors.</p>
|
||||||
|
|
||||||
|
<!-- Main animated walkthrough -->
|
||||||
|
<div style="max-width:960px;margin:0 auto 48px;border-radius:12px;overflow:hidden;border:1px solid var(--border);box-shadow:0 20px 60px rgba(0,0,0,0.6);">
|
||||||
|
<div style="background:var(--dark3);padding:12px 16px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);">
|
||||||
|
<div style="width:12px;height:12px;border-radius:50%;background:#ff5f57;"></div>
|
||||||
|
<div style="width:12px;height:12px;border-radius:50%;background:#febc2e;"></div>
|
||||||
|
<div style="width:12px;height:12px;border-radius:50%;background:#28c840;"></div>
|
||||||
|
<div style="flex:1;text-align:center;font-size:13px;color:var(--text-dim);">BatchTube — localhost:8000</div>
|
||||||
|
</div>
|
||||||
|
<img src="/batchtube-walkthrough.gif" alt="BatchTube full feature walkthrough" style="width:100%;display:block;" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature GIF grid -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:24px;">
|
||||||
|
|
||||||
|
<div style="background:var(--dark2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;">
|
||||||
|
<img src="/feature-new-job.gif" alt="Create a new extraction job" style="width:100%;display:block;" loading="lazy" />
|
||||||
|
<div style="padding:16px;">
|
||||||
|
<div style="font-size:15px;font-weight:700;margin-bottom:6px;">⚡ New Extraction Job</div>
|
||||||
|
<div style="font-size:13px;color:var(--text-dim);">Single URL, playlist, channel handle, or CSV list. Configure what to extract in seconds.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--dark2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;">
|
||||||
|
<img src="/feature-jobs.gif" alt="Track batch processing progress" style="width:100%;display:block;" loading="lazy" />
|
||||||
|
<div style="padding:16px;">
|
||||||
|
<div style="font-size:15px;font-weight:700;margin-bottom:6px;">📊 Live Progress Tracking</div>
|
||||||
|
<div style="font-size:13px;color:var(--text-dim);">Real-time job dashboard. See every batch as it processes — status, progress, completion history.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--dark2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;">
|
||||||
|
<img src="/feature-settings.gif" alt="MCP server integration and settings" style="width:100%;display:block;" loading="lazy" />
|
||||||
|
<div style="padding:16px;">
|
||||||
|
<div style="font-size:15px;font-weight:700;margin-bottom:6px;">🔗 MCP Server Built In</div>
|
||||||
|
<div style="font-size:13px;color:var(--text-dim);">Connect BatchTube directly to Claude, Cursor, or any AI assistant. One config line. Done.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MID-PAGE CTA -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="mid-cta">
|
||||||
|
<h2>Stop doing this manually.</h2>
|
||||||
|
<p>Every hour you spend downloading videos one at a time is an hour you are not creating, researching, or building. BatchTube gives that time back.</p>
|
||||||
|
<a href="#pricing" class="btn-primary">Get BatchTube Free →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TESTIMONIALS -->
|
||||||
|
<section class="section" id="testimonials">
|
||||||
|
<div class="section-label">What People Are Saying</div>
|
||||||
|
<h2 class="section-title">Built for people who work at scale.</h2>
|
||||||
|
<p class="section-sub">Content creators, researchers, educators — anyone who works with YouTube seriously.</p>
|
||||||
|
<div class="testimonials-grid">
|
||||||
|
<div class="testimonial">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">“I research competitor channels for clients every week. BatchTube cut my research time from 6 hours to 45 minutes. I paste the channel URL, walk away, and come back to a full transcript library. Nothing else does this.”</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<div class="testimonial-avatar">M</div>
|
||||||
|
<div><div class="testimonial-name">Marcus T.</div><div class="testimonial-role">YouTube Strategy Consultant</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="testimonial">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">“I teach a media studies course and needed to archive 200+ videos for my curriculum before they disappeared. BatchTube downloaded everything overnight — transcripts included. My students now have a searchable library. Incredible tool.”</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<div class="testimonial-avatar">D</div>
|
||||||
|
<div><div class="testimonial-name">Dr. Diane K.</div><div class="testimonial-role">University Media Studies Professor</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="testimonial">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">“The MCP integration is what sold me. I connected BatchTube directly to Claude and now I can ask questions about any YouTube channel's content without watching a single video. This is the future of research.”</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<div class="testimonial-avatar">R</div>
|
||||||
|
<div><div class="testimonial-name">Ryan S.</div><div class="testimonial-role">AI-Assisted Content Creator</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PRICING -->
|
||||||
|
<section class="section" id="pricing">
|
||||||
|
<div class="section-label">Pricing</div>
|
||||||
|
<h2 class="section-title">Start free. Scale when you're ready.</h2>
|
||||||
|
<p class="section-sub">Every plan runs locally on your machine. Your data never leaves your hard drive.</p>
|
||||||
|
<div class="pricing-grid">
|
||||||
|
|
||||||
|
<div class="pricing-card">
|
||||||
|
<div class="pricing-name">Free</div>
|
||||||
|
<div class="pricing-price">$0</div>
|
||||||
|
<div class="pricing-desc">Try it. No account required.</div>
|
||||||
|
<ul class="pricing-features">
|
||||||
|
<li>3 videos per batch</li>
|
||||||
|
<li>720p max resolution</li>
|
||||||
|
<li>MP3 extraction</li>
|
||||||
|
<li class="dim">Transcripts</li>
|
||||||
|
<li class="dim">Batch CSV upload</li>
|
||||||
|
<li class="dim">AI summaries</li>
|
||||||
|
<li class="dim">Priority support</li>
|
||||||
|
</ul>
|
||||||
|
<a href="#download" class="btn-plan outline">Download Free — Start Today</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pricing-card featured">
|
||||||
|
<div class="pricing-badge">Most Popular</div>
|
||||||
|
<div class="pricing-name">Creator</div>
|
||||||
|
<div class="pricing-price">$19<span>/mo</span></div>
|
||||||
|
<div class="pricing-annual">Or <strong>$190/yr</strong> — save 2 months</div>
|
||||||
|
<div class="pricing-desc">For serious content creators.</div>
|
||||||
|
<ul class="pricing-features">
|
||||||
|
<li>100 downloads per day</li>
|
||||||
|
<li>1080p max resolution</li>
|
||||||
|
<li>MP3 extraction</li>
|
||||||
|
<li>Transcripts</li>
|
||||||
|
<li>Batch CSV upload</li>
|
||||||
|
<li>AI summaries</li>
|
||||||
|
<li class="dim">Priority support</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://qmg.thrivecart.com/batchtube-creator/" class="btn-plan primary">Start Creator — $19/mo →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pricing-card">
|
||||||
|
<div class="pricing-name">Pro</div>
|
||||||
|
<div class="pricing-price">$39<span>/mo</span></div>
|
||||||
|
<div class="pricing-annual">Or <strong>$790/yr</strong> — save with annual</div>
|
||||||
|
<div class="pricing-desc">Unlimited power. Zero limits.</div>
|
||||||
|
<ul class="pricing-features">
|
||||||
|
<li>Unlimited downloads</li>
|
||||||
|
<li>4K max resolution</li>
|
||||||
|
<li>MP3 extraction</li>
|
||||||
|
<li>Transcripts</li>
|
||||||
|
<li>Batch CSV upload</li>
|
||||||
|
<li>AI summaries</li>
|
||||||
|
<li>Priority support</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://qmg.thrivecart.com/batchtube-pro/" class="btn-plan outline">Go Pro — $39/mo →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="pricing-card" style="border-color: #9933ff; background: rgba(153,51,255,0.04);">
|
||||||
|
<div class="pricing-badge" style="background:#9933ff;">🌟 Studio</div>
|
||||||
|
<div class="pricing-name">Studio</div>
|
||||||
|
<div class="pricing-price">$79<span>/mo</span></div>
|
||||||
|
<div class="pricing-annual">Or <strong>$790/yr</strong> — save 2 months</div>
|
||||||
|
<div class="pricing-desc">For agencies & power teams.</div>
|
||||||
|
<ul class="pricing-features">
|
||||||
|
<li>Unlimited downloads</li>
|
||||||
|
<li>4K max resolution</li>
|
||||||
|
<li>MP3 extraction</li>
|
||||||
|
<li>Transcripts</li>
|
||||||
|
<li>Batch CSV upload</li>
|
||||||
|
<li>AI summaries</li>
|
||||||
|
<li>3 team seats</li>
|
||||||
|
<li>API access</li>
|
||||||
|
<li>Priority support</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://qmg.thrivecart.com/batchtube-studio/" class="btn-plan primary" style="background:#9933ff;">Get Studio — $79/mo →</a>
|
||||||
|
</div>
|
||||||
|
<div class="pricing-card" style="border-color: #ffcc00; background: rgba(255,204,0,0.03);">
|
||||||
|
<div class="pricing-badge" style="background:#ffcc00; color:#000;">🔥 Best Value</div>
|
||||||
|
<div class="pricing-name">Lifetime</div>
|
||||||
|
<div class="pricing-price">$197<span> once</span></div>
|
||||||
|
<div class="pricing-desc">Pay once. Own it forever.</div>
|
||||||
|
<div class="scarcity-box"><strong>🔥 FOUNDING MEMBER PRICE — First 100 licenses only</strong>After 100 licenses: price goes to $247. Forever.</div>
|
||||||
|
<ul class="pricing-features">
|
||||||
|
<li>Unlimited downloads</li>
|
||||||
|
<li>4K max resolution</li>
|
||||||
|
<li>MP3 extraction</li>
|
||||||
|
<li>Transcripts</li>
|
||||||
|
<li>Batch CSV upload</li>
|
||||||
|
<li>AI summaries</li>
|
||||||
|
<li>Lifetime updates</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://qmg.thrivecart.com/batchtube-lifetime/" class="btn-plan primary" style="background:#ffcc00; color:#000;">⚡ Lock in $197 before it's gone →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p class="pricing-guarantee">🛡 30-Day Money-Back Guarantee on all paid plans • No questions asked</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<section class="section" id="faq">
|
||||||
|
<div class="section-label">FAQ</div>
|
||||||
|
<h2 class="section-title">Questions answered.</h2>
|
||||||
|
<div class="faq">
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">Is downloading YouTube videos legal?</div>
|
||||||
|
<div class="faq-a">Downloading YouTube videos for personal use, research, education, and archival purposes is widely practiced and legally protected in many jurisdictions under fair use doctrine. BatchTube is used by researchers, educators, journalists, and content archivists worldwide. We do not scrape YouTube's platform or violate rate limits. Use BatchTube responsibly, within the bounds of your intended purpose.</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">Does BatchTube work on Mac and Linux?</div>
|
||||||
|
<div class="faq-a">Yes. BatchTube runs on Windows, macOS, and Linux. The application runs a local server that opens in your browser — the same interface works identically across all three platforms. Windows users get a one-click start.bat launcher. Mac and Linux users run a shell script.</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">Does BatchTube violate YouTube's Terms of Service?</div>
|
||||||
|
<div class="faq-a">BatchTube is powered by a robust open-source engine maintained by a global developer community. YouTube’s ToS restricts commercial redistribution of downloaded content — not personal use or research. BatchTube does not redistribute content. It downloads to your local machine for your own use. Consult your own legal counsel if you have specific compliance questions.</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">Does BatchTube require an internet connection?</div>
|
||||||
|
<div class="faq-a">BatchTube needs an internet connection to download content from YouTube — that is the nature of the task. But the application itself runs entirely locally on your machine. Your downloaded files, transcripts, and AI summaries are stored on your hard drive. No cloud account required. No data sent to our servers. Your content stays yours.</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">How accurate are the transcripts and AI summaries?</div>
|
||||||
|
<div class="faq-a">Transcripts are generated by Whisper — OpenAI's speech recognition model — running locally on your machine. Accuracy is typically 95%+ for clear English audio. AI summaries extract key points, topics, and takeaways from the transcript automatically. Both outputs are editable and exportable.</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">What happens if YouTube changes something and BatchTube breaks?</div>
|
||||||
|
<div class="faq-a">BatchTube’s download engine is powered by a robust open-source engine maintained by a global developer community that pushes updates within hours of any YouTube change. Creator and Pro subscribers receive automatic updates. Lifetime customers receive every update forever. Your workflow stays uninterrupted.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BOTTOM CTA -->
|
||||||
|
<div class="bottom-cta">
|
||||||
|
<h2>Your competitors are already automating their research.</h2>
|
||||||
|
<p>Are you still downloading one video at a time?</p>
|
||||||
|
<a href="#pricing" class="btn-primary" style="font-size:18px; padding:18px 44px;">Download BatchTube Now — Free to Start →</a>
|
||||||
|
<p style="margin-top:16px; font-size:13px; color:var(--text-dim);">No account required • Windows, Mac & Linux • 30-day guarantee on paid plans</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer>
|
||||||
|
<div class="footer-logo">Batch<span>Tube</span></div>
|
||||||
|
<p style="margin-bottom:12px;">Built by <a href="https://intentures.org">Intentures LLC</a> • <a href="https://mautic.intentures.org/form/2" target="_blank" rel="noopener">Contact Us</a></p>
|
||||||
|
<p>© 2026 Intentures LLC. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user