feat: enforce free tier limits (3 videos/batch, 720p max)

- config.py: add BATCHTUBE_PLAN env var, FREE_TIER_MAX_VIDEOS=3,
  FREE_TIER_MAX_RESOLUTION=720, PLAN_LIMITS map for all tiers
- api_server.py: cap resolution to 720p on free plan, reject CSV
  batches > 3 videos with 403 + upgrade prompt
- worker.py: truncate resolved video list to max_videos on free plan,
  emit tier_limit SSE event with upgrade message

Pricing tiers (2026-03-07 approved):
  Free: 3 videos/batch, 720p | Creator: $19/mo | Pro: $39/mo
  Studio: $79/mo | Lifetime: $197
This commit is contained in:
Agent Developer
2026-03-08 00:54:00 +00:00
parent 069dfc0ae4
commit c9041bde01
3 changed files with 1040 additions and 952 deletions

View File

@@ -1,443 +1,477 @@
"""
api_server.py - FastAPI backend for BatchTube
Serves the Web GUI, REST API, and Server-Sent Events (SSE) for real-time progress.
"""
import sys
import os
from pathlib import Path
# Ensure project directory is always on sys.path (needed for some Python installations)
_PROJECT_DIR = Path(__file__).parent.resolve()
if str(_PROJECT_DIR) not in sys.path:
sys.path.insert(0, str(_PROJECT_DIR))
import json
import asyncio
import logging
from typing import Optional, List
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
import database as db
import worker
import config
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("api_server")
app = FastAPI(title="BatchTube", version="1.0.0")
# CORS - allow localhost GUI access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Serve static web files
WEB_DIR = Path(__file__).parent / "web"
# ─────────────────────────────────────────────────────────────────────────────
# Request / Response Models
# ─────────────────────────────────────────────────────────────────────────────
class CreateJobRequest(BaseModel):
input_type: str # url | playlist | csv | handle
input_value: str # the URL/handle/CSV text
output_folder: str # absolute path to save files
selections: List[str] # artifact types to extract
resolution: str = "best" # for MP4
delay_min: float = 2.0
delay_max: float = 7.0
openrouter_api_key: str = ""
openrouter_model: str = "openai/gpt-4o-mini"
summary_prompt: str = ""
class ResumeJobRequest(BaseModel):
openrouter_api_key: str = ""
class BrowseFolderRequest(BaseModel):
path: str = ""
# ─────────────────────────────────────────────────────────────────────────────
# Web GUI Serving
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def serve_gui():
index_path = WEB_DIR / "index.html"
if not index_path.exists():
return HTMLResponse("<h1>Web GUI not found. Check the web/ directory.</h1>", status_code=404)
return HTMLResponse(index_path.read_text(encoding="utf-8"))
@app.get("/app.js")
async def serve_js():
return FileResponse(WEB_DIR / "app.js", media_type="application/javascript")
@app.get("/style.css")
async def serve_css():
return FileResponse(WEB_DIR / "style.css", media_type="text/css")
# ─────────────────────────────────────────────────────────────────────────────
# Job Management API
# ─────────────────────────────────────────────────────────────────────────────
@app.post("/api/jobs")
async def create_job(req: CreateJobRequest):
"""Create and immediately launch a new extraction job."""
# Validate output folder
output_folder = req.output_folder.strip()
if not output_folder:
raise HTTPException(status_code=400, detail="output_folder is required")
# Validate selections
valid_types = set(config.ARTIFACT_TYPES)
invalid = [s for s in req.selections if s not in valid_types]
if invalid:
raise HTTPException(status_code=400, detail=f"Invalid artifact types: {invalid}")
if not req.selections:
raise HTTPException(status_code=400, detail="At least one artifact type must be selected")
# Validate input
if not req.input_value.strip():
raise HTTPException(status_code=400, detail="input_value is required")
# Create the folder if it doesn't exist
try:
Path(output_folder).mkdir(parents=True, exist_ok=True)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Cannot create output folder: {e}")
# Create job in DB
job_id = db.create_job(
output_folder=output_folder,
selections=req.selections,
resolution=req.resolution,
delay_min=req.delay_min,
delay_max=req.delay_max,
input_type=req.input_type,
input_value=req.input_value,
summary_prompt=req.summary_prompt or config.SUMMARY_SYSTEM_PROMPT,
openrouter_model=req.openrouter_model or config.OPENROUTER_MODEL,
)
# Launch background worker thread
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
logger.info(f"Created and launched job {job_id}")
return {"job_id": job_id, "status": "running"}
@app.get("/api/jobs")
async def list_jobs():
"""List all jobs (most recent first)."""
jobs = db.list_jobs()
result = []
for j in jobs:
stats = db.get_job_stats(j["job_id"])
j.update(stats)
result.append(j)
return result
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
"""Get job details + stats."""
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
stats = db.get_job_stats(job_id)
job.update(stats)
return job
@app.post("/api/jobs/{job_id}/pause")
async def pause_job(job_id: str):
job = db.get_job(job_id)
if not job:
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']}")
worker.pause_job(job_id)
return {"job_id": job_id, "status": "paused"}
@app.post("/api/jobs/{job_id}/resume")
async def resume_job(job_id: str, req: ResumeJobRequest = ResumeJobRequest()):
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job["status"] == "paused":
# Job thread is still alive, just unpause it
worker.resume_job(job_id)
return {"job_id": job_id, "status": "running"}
elif job["status"] in ("stopped", "failed", "done_with_errors"):
# Re-launch the job from where it left off
db.update_job_status(job_id, "pending")
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
return {"job_id": job_id, "status": "running", "note": "Job re-launched to resume from last checkpoint"}
else:
raise HTTPException(status_code=400, detail=f"Cannot resume job with status: {job['status']}")
@app.post("/api/jobs/{job_id}/stop")
async def stop_job(job_id: str):
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
worker.stop_job(job_id)
return {"job_id": job_id, "status": "stopped"}
@app.post("/api/jobs/{job_id}/skip")
async def skip_video(job_id: str):
"""Skip the currently processing video."""
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
worker.skip_video(job_id)
return {"job_id": job_id, "action": "skip_sent"}
# ─────────────────────────────────────────────────────────────────────────────
# Videos & Artifacts
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/videos")
async def get_videos(job_id: str):
videos = db.get_videos(job_id)
if not videos and not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
# Enrich with artifact statuses
result = []
for v in videos:
arts = db.get_artifacts_for_video(job_id, v["video_id"])
v["artifacts"] = arts
result.append(v)
return result
@app.get("/api/jobs/{job_id}/artifacts")
async def get_artifacts(job_id: str):
return db.get_all_artifacts(job_id)
# ─────────────────────────────────────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/logs")
async def get_logs(job_id: str, limit: int = 500):
if not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
return db.get_logs(job_id, limit=limit)
@app.get("/api/jobs/{job_id}/logs/csv")
async def download_log_csv(job_id: str):
"""Download artifact log as CSV."""
if not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
csv_content = db.get_log_csv(job_id)
return StreamingResponse(
iter([csv_content]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="job_{job_id[:8]}_log.csv"'},
)
# ─────────────────────────────────────────────────────────────────────────────
# Server-Sent Events (SSE) for real-time updates
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/events")
async def job_events(job_id: str, request: Request):
"""
SSE endpoint. Client subscribes and receives real-time events
as the worker processes artifacts.
"""
if not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
queue = worker.emitter.subscribe(job_id)
async def event_generator():
try:
# Send initial state snapshot
job = db.get_job(job_id)
stats = db.get_job_stats(job_id)
if job:
job.update(stats)
yield _sse("job_status", {"job_id": job_id, "status": job["status"], "stats": stats})
while True:
if await request.is_disconnected():
break
# Poll the queue with a short timeout
event = await asyncio.get_event_loop().run_in_executor(
None, lambda: queue.get(timeout=1.0)
)
if event:
yield _sse(event["type"], event["data"])
else:
# Send a keepalive comment
yield ": keepalive\n\n"
# Check if job is complete
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)
# Drain remaining queue events
while True:
event = await asyncio.get_event_loop().run_in_executor(
None, lambda: queue.get(timeout=0.1)
)
if event:
yield _sse(event["type"], event["data"])
else:
break
yield _sse("stream_end", {"job_id": job_id})
break
except asyncio.CancelledError:
pass
finally:
worker.emitter.unsubscribe(job_id, queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
def _sse(event_type: str, data: dict) -> str:
"""Format a Server-Sent Event string."""
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
# ─────────────────────────────────────────────────────────────────────────────
# File System Browsing Helper
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/browse")
async def browse_folder(path: str = ""):
"""
Return directory listing for folder picking in the GUI.
Defaults to user home directory.
"""
if not path:
path = str(Path.home())
target = Path(path)
if not target.exists() or not target.is_dir():
# Fallback to home
target = Path.home()
try:
entries = []
for entry in sorted(target.iterdir()):
try:
entries.append({
"name": entry.name,
"path": str(entry.absolute()),
"is_dir": entry.is_dir(),
})
except PermissionError:
pass
# Add parent directory option
parent = str(target.parent.absolute())
return {
"current": str(target.absolute()),
"parent": parent if parent != str(target.absolute()) else None,
"entries": entries,
}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
@app.post("/api/mkdir")
async def make_dir(req: BrowseFolderRequest):
"""Create a new directory."""
if not req.path:
raise HTTPException(status_code=400, detail="path is required")
try:
Path(req.path).mkdir(parents=True, exist_ok=True)
return {"created": req.path}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Config / Settings API
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/config")
async def get_config():
"""Return non-sensitive defaults for the GUI."""
return {
"available_resolutions": config.AVAILABLE_RESOLUTIONS,
"artifact_types": config.ARTIFACT_TYPES,
"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",
)
"""
api_server.py - FastAPI backend for BatchTube
Serves the Web GUI, REST API, and Server-Sent Events (SSE) for real-time progress.
"""
import sys
import os
from pathlib import Path
# Ensure project directory is always on sys.path (needed for some Python installations)
_PROJECT_DIR = Path(__file__).parent.resolve()
if str(_PROJECT_DIR) not in sys.path:
sys.path.insert(0, str(_PROJECT_DIR))
import json
import asyncio
import logging
from typing import Optional, List
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
import database as db
import worker
import config
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("api_server")
app = FastAPI(title="BatchTube", version="1.0.0")
# CORS - allow localhost GUI access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Serve static web files
WEB_DIR = Path(__file__).parent / "web"
# ─────────────────────────────────────────────────────────────────────────────
# Request / Response Models
# ─────────────────────────────────────────────────────────────────────────────
class CreateJobRequest(BaseModel):
input_type: str # url | playlist | csv | handle
input_value: str # the URL/handle/CSV text
output_folder: str # absolute path to save files
selections: List[str] # artifact types to extract
resolution: str = "best" # for MP4
delay_min: float = 2.0
delay_max: float = 7.0
openrouter_api_key: str = ""
openrouter_model: str = "openai/gpt-4o-mini"
summary_prompt: str = ""
class ResumeJobRequest(BaseModel):
openrouter_api_key: str = ""
class BrowseFolderRequest(BaseModel):
path: str = ""
# ─────────────────────────────────────────────────────────────────────────────
# Web GUI Serving
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def serve_gui():
index_path = WEB_DIR / "index.html"
if not index_path.exists():
return HTMLResponse("<h1>Web GUI not found. Check the web/ directory.</h1>", status_code=404)
return HTMLResponse(index_path.read_text(encoding="utf-8"))
@app.get("/app.js")
async def serve_js():
return FileResponse(WEB_DIR / "app.js", media_type="application/javascript")
@app.get("/style.css")
async def serve_css():
return FileResponse(WEB_DIR / "style.css", media_type="text/css")
# ─────────────────────────────────────────────────────────────────────────────
# Job Management API
# ─────────────────────────────────────────────────────────────────────────────
@app.post("/api/jobs")
async def create_job(req: CreateJobRequest):
"""Create and immediately launch a new extraction job."""
# Validate output folder
output_folder = req.output_folder.strip()
if not output_folder:
raise HTTPException(status_code=400, detail="output_folder is required")
# Validate selections
valid_types = set(config.ARTIFACT_TYPES)
invalid = [s for s in req.selections if s not in valid_types]
if invalid:
raise HTTPException(status_code=400, detail=f"Invalid artifact types: {invalid}")
if not req.selections:
raise HTTPException(status_code=400, detail="At least one artifact type must be selected")
# Validate input
if not req.input_value.strip():
raise HTTPException(status_code=400, detail="input_value is required")
# ── Free Tier Enforcement ─────────────────────────────────────────────────
plan = config.BATCHTUBE_PLAN
limits = config.PLAN_LIMITS.get(plan, config.PLAN_LIMITS["free"])
# Cap resolution for free tier
resolution = req.resolution
max_res = limits.get("max_resolution")
if max_res and max_res != "best":
res_order = config.AVAILABLE_RESOLUTIONS # ["best", "4320", ..., "144"]
# Lower index = higher quality; cap if requested res is higher than allowed
def res_index(r):
try:
return res_order.index(r)
except ValueError:
return len(res_order)
if res_index(resolution) < res_index(max_res):
logger.info(f"Free tier: capping resolution from {resolution} to {max_res}")
resolution = max_res
else:
resolution = req.resolution
# For CSV input: enforce max_videos limit upfront
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()]
if len(csv_lines) > max_videos:
raise HTTPException(
status_code=403,
detail=f"Free tier is limited to {max_videos} videos per batch. "
f"Upgrade to Creator or Pro for larger batches. "
f"({len(csv_lines)} URLs submitted)"
)
# Create the folder if it doesn't exist
try:
Path(output_folder).mkdir(parents=True, exist_ok=True)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Cannot create output folder: {e}")
# Create job in DB
job_id = db.create_job(
output_folder=output_folder,
selections=req.selections,
resolution=resolution, # may be capped by free tier enforcement
delay_min=req.delay_min,
delay_max=req.delay_max,
input_type=req.input_type,
input_value=req.input_value,
summary_prompt=req.summary_prompt or config.SUMMARY_SYSTEM_PROMPT,
openrouter_model=req.openrouter_model or config.OPENROUTER_MODEL,
)
# Launch background worker thread
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
logger.info(f"Created and launched job {job_id}")
return {"job_id": job_id, "status": "running"}
@app.get("/api/jobs")
async def list_jobs():
"""List all jobs (most recent first)."""
jobs = db.list_jobs()
result = []
for j in jobs:
stats = db.get_job_stats(j["job_id"])
j.update(stats)
result.append(j)
return result
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
"""Get job details + stats."""
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
stats = db.get_job_stats(job_id)
job.update(stats)
return job
@app.post("/api/jobs/{job_id}/pause")
async def pause_job(job_id: str):
job = db.get_job(job_id)
if not job:
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']}")
worker.pause_job(job_id)
return {"job_id": job_id, "status": "paused"}
@app.post("/api/jobs/{job_id}/resume")
async def resume_job(job_id: str, req: ResumeJobRequest = ResumeJobRequest()):
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job["status"] == "paused":
# Job thread is still alive, just unpause it
worker.resume_job(job_id)
return {"job_id": job_id, "status": "running"}
elif job["status"] in ("stopped", "failed", "done_with_errors"):
# Re-launch the job from where it left off
db.update_job_status(job_id, "pending")
worker.launch_job(job_id, openrouter_api_key=req.openrouter_api_key)
return {"job_id": job_id, "status": "running", "note": "Job re-launched to resume from last checkpoint"}
else:
raise HTTPException(status_code=400, detail=f"Cannot resume job with status: {job['status']}")
@app.post("/api/jobs/{job_id}/stop")
async def stop_job(job_id: str):
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
worker.stop_job(job_id)
return {"job_id": job_id, "status": "stopped"}
@app.post("/api/jobs/{job_id}/skip")
async def skip_video(job_id: str):
"""Skip the currently processing video."""
job = db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
worker.skip_video(job_id)
return {"job_id": job_id, "action": "skip_sent"}
# ─────────────────────────────────────────────────────────────────────────────
# Videos & Artifacts
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/videos")
async def get_videos(job_id: str):
videos = db.get_videos(job_id)
if not videos and not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
# Enrich with artifact statuses
result = []
for v in videos:
arts = db.get_artifacts_for_video(job_id, v["video_id"])
v["artifacts"] = arts
result.append(v)
return result
@app.get("/api/jobs/{job_id}/artifacts")
async def get_artifacts(job_id: str):
return db.get_all_artifacts(job_id)
# ─────────────────────────────────────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/logs")
async def get_logs(job_id: str, limit: int = 500):
if not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
return db.get_logs(job_id, limit=limit)
@app.get("/api/jobs/{job_id}/logs/csv")
async def download_log_csv(job_id: str):
"""Download artifact log as CSV."""
if not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
csv_content = db.get_log_csv(job_id)
return StreamingResponse(
iter([csv_content]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="job_{job_id[:8]}_log.csv"'},
)
# ─────────────────────────────────────────────────────────────────────────────
# Server-Sent Events (SSE) for real-time updates
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/events")
async def job_events(job_id: str, request: Request):
"""
SSE endpoint. Client subscribes and receives real-time events
as the worker processes artifacts.
"""
if not db.get_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
queue = worker.emitter.subscribe(job_id)
async def event_generator():
try:
# Send initial state snapshot
job = db.get_job(job_id)
stats = db.get_job_stats(job_id)
if job:
job.update(stats)
yield _sse("job_status", {"job_id": job_id, "status": job["status"], "stats": stats})
while True:
if await request.is_disconnected():
break
# Poll the queue with a short timeout
event = await asyncio.get_event_loop().run_in_executor(
None, lambda: queue.get(timeout=1.0)
)
if event:
yield _sse(event["type"], event["data"])
else:
# Send a keepalive comment
yield ": keepalive\n\n"
# Check if job is complete
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)
# Drain remaining queue events
while True:
event = await asyncio.get_event_loop().run_in_executor(
None, lambda: queue.get(timeout=0.1)
)
if event:
yield _sse(event["type"], event["data"])
else:
break
yield _sse("stream_end", {"job_id": job_id})
break
except asyncio.CancelledError:
pass
finally:
worker.emitter.unsubscribe(job_id, queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
def _sse(event_type: str, data: dict) -> str:
"""Format a Server-Sent Event string."""
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
# ─────────────────────────────────────────────────────────────────────────────
# File System Browsing Helper
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/browse")
async def browse_folder(path: str = ""):
"""
Return directory listing for folder picking in the GUI.
Defaults to user home directory.
"""
if not path:
path = str(Path.home())
target = Path(path)
if not target.exists() or not target.is_dir():
# Fallback to home
target = Path.home()
try:
entries = []
for entry in sorted(target.iterdir()):
try:
entries.append({
"name": entry.name,
"path": str(entry.absolute()),
"is_dir": entry.is_dir(),
})
except PermissionError:
pass
# Add parent directory option
parent = str(target.parent.absolute())
return {
"current": str(target.absolute()),
"parent": parent if parent != str(target.absolute()) else None,
"entries": entries,
}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
@app.post("/api/mkdir")
async def make_dir(req: BrowseFolderRequest):
"""Create a new directory."""
if not req.path:
raise HTTPException(status_code=400, detail="path is required")
try:
Path(req.path).mkdir(parents=True, exist_ok=True)
return {"created": req.path}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Config / Settings API
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/api/config")
async def get_config():
"""Return non-sensitive defaults for the GUI."""
return {
"available_resolutions": config.AVAILABLE_RESOLUTIONS,
"artifact_types": config.ARTIFACT_TYPES,
"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",
)

View File

@@ -1,5 +1,5 @@
"""
config.py - Default configuration for YouTube SIPOC Tool
config.py - Default configuration for BatchTube
"""
import os
@@ -32,7 +32,40 @@ SUMMARY_MAX_TOKENS = 1024
MAX_TITLE_LENGTH = 80 # characters - truncate long titles in filenames
# ── Database ──────────────────────────────────────────────────────────────────
DB_PATH = os.environ.get("BATCHTUBE_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(
"BATCHTUBE_DB_PATH",
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

File diff suppressed because it is too large Load Diff