feat: initial Tauri desktop shell for BatchTube v1.0
This commit is contained in:
443
backend/api_server.py
Normal file
443
backend/api_server.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
api_server.py - FastAPI backend for YouTube SIPOC Tool
|
||||
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="YouTube SIPOC Tool", 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(" YouTube SIPOC Tool")
|
||||
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",
|
||||
)
|
||||
64
backend/batchtube-server.spec
Normal file
64
backend/batchtube-server.spec
Normal file
@@ -0,0 +1,64 @@
|
||||
# batchtube-server.spec
|
||||
# PyInstaller spec to bundle the BatchTube FastAPI backend
|
||||
# Run: pyinstaller batchtube-server.spec
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
['api_server.py'],
|
||||
pathex=[str(Path('.'). resolve())],
|
||||
binaries=[],
|
||||
datas=[
|
||||
('web', 'web'),
|
||||
('youtube_sipoc.db', '.'),
|
||||
],
|
||||
hiddenimports=[
|
||||
'uvicorn.logging',
|
||||
'uvicorn.loops',
|
||||
'uvicorn.loops.auto',
|
||||
'uvicorn.protocols',
|
||||
'uvicorn.protocols.http',
|
||||
'uvicorn.protocols.http.auto',
|
||||
'uvicorn.protocols.websockets',
|
||||
'uvicorn.protocols.websockets.auto',
|
||||
'uvicorn.lifespan',
|
||||
'uvicorn.lifespan.on',
|
||||
'fastapi',
|
||||
'yt_dlp',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
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-server',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # No console window
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
38
backend/config.py
Normal file
38
backend/config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
config.py - Default configuration for YouTube SIPOC Tool
|
||||
"""
|
||||
import os
|
||||
|
||||
# ── API Settings ──────────────────────────────────────────────────────────────
|
||||
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODEL = os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o-mini")
|
||||
|
||||
# ── Server Settings ───────────────────────────────────────────────────────────
|
||||
API_HOST = "127.0.0.1"
|
||||
API_PORT = 8765
|
||||
|
||||
# ── Download / Bot Avoidance ──────────────────────────────────────────────────
|
||||
DELAY_MIN_SECONDS = 2.0 # minimum random pause between artifacts
|
||||
DELAY_MAX_SECONDS = 7.0 # maximum random pause between artifacts
|
||||
MAX_RETRIES = 3 # max retry attempts per failed artifact
|
||||
|
||||
# ── Video Download ────────────────────────────────────────────────────────────
|
||||
DEFAULT_VIDEO_RESOLUTION = "1080" # used if not specified by user
|
||||
AVAILABLE_RESOLUTIONS = ["best", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
SUMMARY_SYSTEM_PROMPT = (
|
||||
"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."
|
||||
)
|
||||
SUMMARY_MAX_TOKENS = 1024
|
||||
|
||||
# ── File Naming ───────────────────────────────────────────────────────────────
|
||||
MAX_TITLE_LENGTH = 80 # characters - truncate long titles in filenames
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "youtube_sipoc.db")
|
||||
|
||||
# ── Artifact Types ────────────────────────────────────────────────────────────
|
||||
ARTIFACT_TYPES = ["title", "stats", "transcript", "summary", "thumbnail", "mp3", "mp4"]
|
||||
371
backend/database.py
Normal file
371
backend/database.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
database.py - SQLite state management for YouTube SIPOC Tool
|
||||
Handles jobs, videos, artifacts, and logs with resume capability.
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import config
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
"""Context manager providing a thread-safe SQLite connection."""
|
||||
conn = sqlite3.connect(config.DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL") # WAL mode for concurrent reads
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Create all tables if they don't exist."""
|
||||
with get_conn() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
output_folder TEXT NOT NULL,
|
||||
selections TEXT NOT NULL,
|
||||
resolution TEXT NOT NULL DEFAULT 'best',
|
||||
delay_min REAL NOT NULL DEFAULT 2.0,
|
||||
delay_max REAL NOT NULL DEFAULT 7.0,
|
||||
input_type TEXT NOT NULL DEFAULT 'url',
|
||||
input_value TEXT NOT NULL DEFAULT '',
|
||||
summary_prompt TEXT NOT NULL DEFAULT '',
|
||||
openrouter_model TEXT NOT NULL DEFAULT '',
|
||||
total_videos INTEGER NOT NULL DEFAULT 0,
|
||||
done_videos INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(job_id),
|
||||
video_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
UNIQUE(job_id, video_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artifacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(job_id),
|
||||
video_id TEXT NOT NULL,
|
||||
artifact_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
file_path TEXT,
|
||||
error_msg TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
UNIQUE(job_id, video_id, artifact_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(job_id),
|
||||
video_id TEXT,
|
||||
artifact_type TEXT,
|
||||
level TEXT NOT NULL DEFAULT 'INFO',
|
||||
message TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_job ON artifacts(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_job ON videos(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_job ON logs(job_id);
|
||||
""")
|
||||
|
||||
|
||||
# ── Job Operations ────────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
output_folder: str,
|
||||
selections: List[str],
|
||||
resolution: str,
|
||||
delay_min: float,
|
||||
delay_max: float,
|
||||
input_type: str,
|
||||
input_value: str,
|
||||
summary_prompt: str,
|
||||
openrouter_model: str,
|
||||
) -> str:
|
||||
"""Create a new job and return its job_id."""
|
||||
job_id = str(uuid.uuid4())
|
||||
now = datetime.utcnow().isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO jobs
|
||||
(job_id, status, created_at, updated_at, output_folder, selections,
|
||||
resolution, delay_min, delay_max, input_type, input_value,
|
||||
summary_prompt, openrouter_model)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
job_id, "pending", now, now, output_folder,
|
||||
json.dumps(selections), resolution,
|
||||
delay_min, delay_max, input_type, input_value,
|
||||
summary_prompt, openrouter_model,
|
||||
),
|
||||
)
|
||||
return job_id
|
||||
|
||||
|
||||
def get_job(job_id: str) -> Optional[Dict]:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["selections"] = json.loads(d["selections"])
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def list_jobs() -> List[Dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
d["selections"] = json.loads(d["selections"])
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
def update_job_status(job_id: str, status: str):
|
||||
now = datetime.utcnow().isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET status=?, updated_at=? WHERE job_id=?",
|
||||
(status, now, job_id),
|
||||
)
|
||||
|
||||
|
||||
def update_job_progress(job_id: str, total: int, done: int):
|
||||
now = datetime.utcnow().isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET total_videos=?, done_videos=?, updated_at=? WHERE job_id=?",
|
||||
(total, done, now, job_id),
|
||||
)
|
||||
|
||||
|
||||
def set_job_field(job_id: str, field: str, value: Any):
|
||||
"""Update a single field on the jobs table (safe whitelist)."""
|
||||
allowed = {"status", "total_videos", "done_videos", "updated_at"}
|
||||
if field not in allowed:
|
||||
raise ValueError(f"Field '{field}' not allowed")
|
||||
now = datetime.utcnow().isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(f"UPDATE jobs SET {field}=?, updated_at=? WHERE job_id=?", (value, now, job_id))
|
||||
|
||||
|
||||
# ── Video Operations ──────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_video(job_id: str, video_id: str, url: str, title: str = "", position: int = 0):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO videos (job_id, video_id, title, url, position, status)
|
||||
VALUES (?,?,?,?,?,'pending')
|
||||
ON CONFLICT(job_id, video_id) DO UPDATE SET
|
||||
title=excluded.title,
|
||||
position=excluded.position""",
|
||||
(job_id, video_id, title, url, position),
|
||||
)
|
||||
|
||||
|
||||
def update_video_title(job_id: str, video_id: str, title: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE videos SET title=? WHERE job_id=? AND video_id=?",
|
||||
(title, job_id, video_id),
|
||||
)
|
||||
|
||||
|
||||
def update_video_status(job_id: str, video_id: str, status: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE videos SET status=? WHERE job_id=? AND video_id=?",
|
||||
(status, job_id, video_id),
|
||||
)
|
||||
|
||||
|
||||
def get_videos(job_id: str) -> List[Dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM videos WHERE job_id=? ORDER BY position ASC",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Artifact Operations ───────────────────────────────────────────────────────
|
||||
|
||||
def upsert_artifact(job_id: str, video_id: str, artifact_type: str):
|
||||
"""Create artifact record if not exists (skip if already done/skipped)."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO artifacts (job_id, video_id, artifact_type, status)
|
||||
VALUES (?,?,?,'pending')
|
||||
ON CONFLICT(job_id, video_id, artifact_type) DO NOTHING""",
|
||||
(job_id, video_id, artifact_type),
|
||||
)
|
||||
|
||||
|
||||
def get_artifact(job_id: str, video_id: str, artifact_type: str) -> Optional[Dict]:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM artifacts WHERE job_id=? AND video_id=? AND artifact_type=?",
|
||||
(job_id, video_id, artifact_type),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def artifact_is_done(job_id: str, video_id: str, artifact_type: str) -> bool:
|
||||
"""Return True if artifact already succeeded or was skipped."""
|
||||
art = get_artifact(job_id, video_id, artifact_type)
|
||||
return art is not None and art["status"] in ("done", "skipped")
|
||||
|
||||
|
||||
def update_artifact_status(
|
||||
job_id: str,
|
||||
video_id: str,
|
||||
artifact_type: str,
|
||||
status: str,
|
||||
file_path: Optional[str] = None,
|
||||
error_msg: Optional[str] = None,
|
||||
):
|
||||
now = datetime.utcnow().isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE artifacts
|
||||
SET status=?, file_path=?, error_msg=?, finished_at=?,
|
||||
attempts = attempts + CASE WHEN ? IN ('done','failed','skipped') THEN 1 ELSE 0 END
|
||||
WHERE job_id=? AND video_id=? AND artifact_type=?""",
|
||||
(status, file_path, error_msg, now, status, job_id, video_id, artifact_type),
|
||||
)
|
||||
|
||||
|
||||
def mark_artifact_started(job_id: str, video_id: str, artifact_type: str):
|
||||
now = datetime.utcnow().isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE artifacts SET status='running', started_at=? WHERE job_id=? AND video_id=? AND artifact_type=?",
|
||||
(now, job_id, video_id, artifact_type),
|
||||
)
|
||||
|
||||
|
||||
def get_artifacts_for_video(job_id: str, video_id: str) -> List[Dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM artifacts WHERE job_id=? AND video_id=? ORDER BY id ASC",
|
||||
(job_id, video_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_all_artifacts(job_id: str) -> List[Dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT a.*, v.title as video_title
|
||||
FROM artifacts a
|
||||
JOIN videos v ON a.job_id=v.job_id AND a.video_id=v.video_id
|
||||
WHERE a.job_id=?
|
||||
ORDER BY v.position ASC, a.artifact_type ASC""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def add_log(
|
||||
job_id: str,
|
||||
message: str,
|
||||
level: str = "INFO",
|
||||
video_id: Optional[str] = None,
|
||||
artifact_type: Optional[str] = None,
|
||||
):
|
||||
now = datetime.utcnow().isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO logs (job_id, video_id, artifact_type, level, message, timestamp)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(job_id, video_id, artifact_type, level, message, now),
|
||||
)
|
||||
|
||||
|
||||
def get_logs(job_id: str, limit: int = 500) -> List[Dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM logs WHERE job_id=? ORDER BY id DESC LIMIT ?",
|
||||
(job_id, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in reversed(rows)]
|
||||
|
||||
|
||||
def get_log_csv(job_id: str) -> str:
|
||||
"""Export artifacts as CSV string for download."""
|
||||
import io, csv
|
||||
artifacts = get_all_artifacts(job_id)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["video_id", "video_title", "artifact_type", "status", "file_path", "error_msg", "attempts", "started_at", "finished_at"])
|
||||
for a in artifacts:
|
||||
writer.writerow([
|
||||
a.get("video_id", ""),
|
||||
a.get("video_title", ""),
|
||||
a.get("artifact_type", ""),
|
||||
a.get("status", ""),
|
||||
a.get("file_path", ""),
|
||||
a.get("error_msg", ""),
|
||||
a.get("attempts", 0),
|
||||
a.get("started_at", ""),
|
||||
a.get("finished_at", ""),
|
||||
])
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def get_job_stats(job_id: str) -> Dict:
|
||||
"""Return aggregate counts for a job."""
|
||||
with get_conn() as conn:
|
||||
total_videos = conn.execute(
|
||||
"SELECT COUNT(*) FROM videos WHERE job_id=?", (job_id,)
|
||||
).fetchone()[0]
|
||||
done_videos = conn.execute(
|
||||
"SELECT COUNT(*) FROM videos WHERE job_id=? AND status='done'", (job_id,)
|
||||
).fetchone()[0]
|
||||
artifact_counts = conn.execute(
|
||||
"""SELECT status, COUNT(*) as cnt FROM artifacts WHERE job_id=? GROUP BY status""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
counts = {row["status"]: row["cnt"] for row in artifact_counts}
|
||||
return {
|
||||
"total_videos": total_videos,
|
||||
"done_videos": done_videos,
|
||||
"artifact_done": counts.get("done", 0),
|
||||
"artifact_failed": counts.get("failed", 0),
|
||||
"artifact_pending": counts.get("pending", 0),
|
||||
"artifact_running": counts.get("running", 0),
|
||||
"artifact_skipped": counts.get("skipped", 0),
|
||||
}
|
||||
|
||||
|
||||
# Initialize on import
|
||||
init_db()
|
||||
749
backend/extractors.py
Normal file
749
backend/extractors.py
Normal file
@@ -0,0 +1,749 @@
|
||||
"""
|
||||
extractors.py - Per-artifact extraction logic for YouTube SIPOC Tool
|
||||
Uses yt-dlp, youtube-transcript-api, and OpenRouter LLM.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
|
||||
import yt_dlp
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
try:
|
||||
from youtube_transcript_api import TranscriptsDisabled, NoTranscriptFound
|
||||
except ImportError:
|
||||
# v1.x moved/renamed exceptions
|
||||
TranscriptsDisabled = Exception
|
||||
NoTranscriptFound = Exception
|
||||
from openai import OpenAI
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger("extractors")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Utilities
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def sanitize_filename(name: str, max_length: int = config.MAX_TITLE_LENGTH) -> str:
|
||||
"""Make a string safe to use in a file name."""
|
||||
name = re.sub(r'[\\/:*?"<>|]', "_", name)
|
||||
name = re.sub(r'\s+', "_", name.strip())
|
||||
name = re.sub(r'_+', "_", name)
|
||||
return name[:max_length].rstrip("._")
|
||||
|
||||
|
||||
def make_base_name(title: str, video_id: str) -> str:
|
||||
"""Return the standard base filename (no extension)."""
|
||||
safe_title = sanitize_filename(title) if title else "untitled"
|
||||
return f"{safe_title}__{video_id}"
|
||||
|
||||
|
||||
def ensure_dir(path: str):
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def video_url(video_id: str) -> str:
|
||||
return f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Input Resolution: URL → list of video IDs
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def resolve_input(input_type: str, input_value: str) -> List[Dict]:
|
||||
"""
|
||||
Resolve any input type to a list of dicts: {video_id, url, title, position}
|
||||
Supported input_types: url, playlist, csv, handle
|
||||
"""
|
||||
if input_type == "url":
|
||||
return _resolve_single_url(input_value)
|
||||
elif input_type == "playlist":
|
||||
return _resolve_playlist(input_value)
|
||||
elif input_type == "csv":
|
||||
return _resolve_csv(input_value)
|
||||
elif input_type == "handle":
|
||||
return _resolve_handle(input_value)
|
||||
else:
|
||||
raise ValueError(f"Unknown input_type: {input_type}")
|
||||
|
||||
|
||||
def _extract_video_id(url: str) -> Optional[str]:
|
||||
"""Extract YouTube video ID from various URL formats."""
|
||||
patterns = [
|
||||
r'(?:v=|youtu\.be/|embed/|shorts/)([A-Za-z0-9_-]{11})',
|
||||
]
|
||||
for pat in patterns:
|
||||
m = re.search(pat, url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_single_url(url: str) -> List[Dict]:
|
||||
"""Handle single YouTube URL (video or short)."""
|
||||
url = url.strip()
|
||||
vid = _extract_video_id(url)
|
||||
if vid:
|
||||
return [{"video_id": vid, "url": video_url(vid), "title": "", "position": 0}]
|
||||
# Fall through to yt-dlp for unusual URLs
|
||||
return _resolve_with_ytdlp(url)
|
||||
|
||||
|
||||
def _resolve_playlist(playlist_url: str) -> List[Dict]:
|
||||
return _resolve_with_ytdlp(playlist_url.strip())
|
||||
|
||||
|
||||
def _resolve_handle(handle: str) -> List[Dict]:
|
||||
"""Resolve @handle or channel URL to video list."""
|
||||
handle = handle.strip()
|
||||
if not handle.startswith("http"):
|
||||
if handle.startswith("@"):
|
||||
url = f"https://www.youtube.com/{handle}/videos"
|
||||
else:
|
||||
url = f"https://www.youtube.com/@{handle}/videos"
|
||||
else:
|
||||
url = handle
|
||||
return _resolve_with_ytdlp(url)
|
||||
|
||||
|
||||
def _resolve_csv(csv_content: str) -> List[Dict]:
|
||||
"""Parse CSV content: each line may be a URL or video_id."""
|
||||
lines = []
|
||||
for line in csv_content.splitlines():
|
||||
for part in line.split(","):
|
||||
part = part.strip().strip('"').strip("'")
|
||||
if part:
|
||||
lines.append(part)
|
||||
results = []
|
||||
for i, entry in enumerate(lines):
|
||||
if entry.startswith("http"):
|
||||
items = _resolve_single_url(entry)
|
||||
for item in items:
|
||||
item["position"] = len(results)
|
||||
results.append(item)
|
||||
else:
|
||||
# Treat as raw video_id
|
||||
vid = entry[:11]
|
||||
results.append({"video_id": vid, "url": video_url(vid), "title": "", "position": len(results)})
|
||||
return results
|
||||
|
||||
|
||||
def _resolve_with_ytdlp(url: str) -> List[Dict]:
|
||||
"""Use yt-dlp to extract video list from any URL (playlist, channel, etc.)."""
|
||||
captured_errors = []
|
||||
|
||||
class ErrorCollector:
|
||||
"""Custom yt-dlp logger to capture warning/error messages."""
|
||||
def debug(self, msg): pass
|
||||
def info(self, msg): pass
|
||||
def warning(self, msg):
|
||||
captured_errors.append(f"WARNING: {msg}")
|
||||
def error(self, msg):
|
||||
captured_errors.append(f"ERROR: {msg}")
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"extract_flat": True,
|
||||
"skip_download": True,
|
||||
"ignoreerrors": False, # Don't swallow errors silently
|
||||
"logger": ErrorCollector(),
|
||||
}
|
||||
results = []
|
||||
info = None
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
except Exception as e:
|
||||
captured_errors.append(str(e))
|
||||
|
||||
if not info:
|
||||
# Build a meaningful error message
|
||||
err_summary = "; ".join(captured_errors) if captured_errors else "No information returned by yt-dlp."
|
||||
# Surface private/login-required as a clear user-facing error
|
||||
lower = err_summary.lower()
|
||||
if "private" in lower:
|
||||
raise ValueError(
|
||||
f"This playlist or channel is PRIVATE. "
|
||||
f"YouTube requires you to be logged in to access it. "
|
||||
f"To process private content, add your YouTube cookies file path in Settings. "
|
||||
f"Details: {err_summary}"
|
||||
)
|
||||
elif "sign in" in lower or "login" in lower or "confirm your age" in lower:
|
||||
raise ValueError(
|
||||
f"YouTube requires authentication (sign-in or age verification). "
|
||||
f"Add your YouTube cookies file path in Settings. "
|
||||
f"Details: {err_summary}"
|
||||
)
|
||||
elif "not exist" in lower or "removed" in lower or "unavailable" in lower:
|
||||
raise ValueError(f"Content not available or removed. Details: {err_summary}")
|
||||
else:
|
||||
raise ValueError(f"Could not resolve URL '{url}'. Details: {err_summary}")
|
||||
|
||||
entries = info.get("entries") or [info]
|
||||
for i, entry in enumerate(entries):
|
||||
if not entry:
|
||||
continue
|
||||
vid = entry.get("id") or entry.get("video_id")
|
||||
if not vid:
|
||||
continue
|
||||
title = entry.get("title", "")
|
||||
results.append({
|
||||
"video_id": vid,
|
||||
"url": video_url(vid),
|
||||
"title": title,
|
||||
"position": i,
|
||||
})
|
||||
|
||||
if not results:
|
||||
raise ValueError(
|
||||
f"URL resolved successfully but contained 0 videos. "
|
||||
f"The playlist/channel may be empty. URL: {url}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Metadata Fetch
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_metadata(video_id: str) -> Dict:
|
||||
"""Fetch full video metadata using yt-dlp."""
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"skip_download": True,
|
||||
"ignoreerrors": True,
|
||||
}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url(video_id), download=False)
|
||||
return info or {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Title
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_title(video_id: str, meta: Dict, output_folder: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Save title as a .txt file.
|
||||
Returns (file_path, title_string)
|
||||
"""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__title.txt")
|
||||
ensure_dir(output_folder)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"Title: {title}\n")
|
||||
f.write(f"Video ID: {video_id}\n")
|
||||
f.write(f"URL: {video_url(video_id)}\n")
|
||||
return file_path, title
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Stats
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_stats(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""Save video stats + full metadata as a human-readable .txt file."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__stats.txt")
|
||||
ensure_dir(output_folder)
|
||||
|
||||
# Format helpers
|
||||
def fmt_num(n) -> str:
|
||||
if n is None:
|
||||
return "N/A"
|
||||
try:
|
||||
return f"{int(n):,}"
|
||||
except Exception:
|
||||
return str(n)
|
||||
|
||||
def fmt_duration(secs) -> str:
|
||||
if not secs:
|
||||
return "N/A"
|
||||
try:
|
||||
secs = int(secs)
|
||||
h, rem = divmod(secs, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}h {m}m {s}s" if h else f"{m}m {s}s"
|
||||
except Exception:
|
||||
return str(secs)
|
||||
|
||||
def fmt_date(d) -> str:
|
||||
if not d:
|
||||
return "N/A"
|
||||
try:
|
||||
d = str(d)
|
||||
return f"{d[:4]}-{d[4:6]}-{d[6:8]}"
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
tags = meta.get("tags") or []
|
||||
categories = meta.get("categories") or []
|
||||
chapters = meta.get("chapters") or []
|
||||
description = meta.get("description", "") or ""
|
||||
formats = meta.get("formats") or []
|
||||
subtitles = meta.get("subtitles") or {}
|
||||
automatic_captions = meta.get("automatic_captions") or {}
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write("=" * 70 + "\n")
|
||||
f.write(f" VIDEO STATS & METADATA\n")
|
||||
f.write("=" * 70 + "\n\n")
|
||||
|
||||
f.write(f"Title : {meta.get('title', '')}\n")
|
||||
f.write(f"Video ID : {video_id}\n")
|
||||
f.write(f"URL : {video_url(video_id)}\n")
|
||||
f.write(f"Webpage URL : {meta.get('webpage_url', '')}\n\n")
|
||||
|
||||
f.write("── Channel ──────────────────────────────────────────────────\n")
|
||||
f.write(f"Channel : {meta.get('channel', meta.get('uploader', ''))}\n")
|
||||
f.write(f"Channel ID : {meta.get('channel_id', '')}\n")
|
||||
f.write(f"Channel URL : {meta.get('channel_url', meta.get('uploader_url', ''))}\n")
|
||||
f.write(f"Subscribers : {fmt_num(meta.get('channel_follower_count'))}\n\n")
|
||||
|
||||
f.write("── Content ──────────────────────────────────────────────────\n")
|
||||
f.write(f"Upload Date : {fmt_date(meta.get('upload_date'))}\n")
|
||||
f.write(f"Duration : {fmt_duration(meta.get('duration'))} ({meta.get('duration_string', '')})\n")
|
||||
f.write(f"Age Limit : {meta.get('age_limit', 0)}\n")
|
||||
f.write(f"Is Live : {meta.get('is_live', False)}\n")
|
||||
f.write(f"Was Live : {meta.get('was_live', False)}\n")
|
||||
f.write(f"Live Status : {meta.get('live_status', '')}\n\n")
|
||||
|
||||
f.write("── Engagement ───────────────────────────────────────────────\n")
|
||||
f.write(f"Views : {fmt_num(meta.get('view_count'))}\n")
|
||||
f.write(f"Likes : {fmt_num(meta.get('like_count'))}\n")
|
||||
f.write(f"Comments : {fmt_num(meta.get('comment_count'))}\n")
|
||||
f.write(f"Average Rating : {meta.get('average_rating', 'N/A')}\n\n")
|
||||
|
||||
f.write("── Media Info ───────────────────────────────────────────────\n")
|
||||
f.write(f"Thumbnail URL : {meta.get('thumbnail', '')}\n")
|
||||
# Best format info
|
||||
best_fmt = next((fmt for fmt in reversed(formats) if fmt.get("vcodec") not in (None, "none")), None)
|
||||
if best_fmt:
|
||||
f.write(f"Best Resolution : {best_fmt.get('width', '?')}x{best_fmt.get('height', '?')} "
|
||||
f"@ {best_fmt.get('fps', '?')}fps\n")
|
||||
f.write(f"Best Video Codec : {best_fmt.get('vcodec', 'N/A')}\n")
|
||||
f.write(f"Best Audio Codec : {best_fmt.get('acodec', 'N/A')}\n")
|
||||
f.write(f"Available Formats: {len(formats)}\n\n")
|
||||
|
||||
f.write("── Classification ───────────────────────────────────────────\n")
|
||||
f.write(f"Categories : {', '.join(categories) if categories else 'N/A'}\n")
|
||||
if tags:
|
||||
tag_str = ", ".join(tags[:30])
|
||||
if len(tags) > 30:
|
||||
tag_str += f" ... (+{len(tags)-30} more)"
|
||||
f.write(f"Tags : {tag_str}\n")
|
||||
else:
|
||||
f.write(f"Tags : N/A\n")
|
||||
f.write("\n")
|
||||
|
||||
# Subtitles / transcript languages
|
||||
all_sub_langs = sorted(set(list(subtitles.keys()) + list(automatic_captions.keys())))
|
||||
if all_sub_langs:
|
||||
f.write("── Subtitles / Captions ─────────────────────────────────────\n")
|
||||
f.write(f"Languages ({len(all_sub_langs)}): {', '.join(all_sub_langs[:20])}")
|
||||
if len(all_sub_langs) > 20:
|
||||
f.write(f" ... (+{len(all_sub_langs)-20} more)")
|
||||
f.write("\n\n")
|
||||
|
||||
# Chapters
|
||||
if chapters:
|
||||
f.write("── Chapters ─────────────────────────────────────────────────\n")
|
||||
for ch in chapters:
|
||||
start = ch.get("start_time", 0)
|
||||
m, s = divmod(int(start), 60)
|
||||
h, m = divmod(m, 60)
|
||||
ts = f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||
f.write(f" [{ts}] {ch.get('title', '')}\n")
|
||||
f.write("\n")
|
||||
|
||||
# Description
|
||||
if description:
|
||||
f.write("── Description ──────────────────────────────────────────────\n")
|
||||
# Limit description to 3000 chars to keep the file manageable
|
||||
if len(description) > 3000:
|
||||
f.write(description[:3000] + "\n... [truncated]\n")
|
||||
else:
|
||||
f.write(description + "\n")
|
||||
f.write("\n")
|
||||
|
||||
f.write("── Extraction Info ──────────────────────────────────────────\n")
|
||||
f.write(f"Extracted At : {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n")
|
||||
f.write(f"Extractor : {meta.get('extractor', 'youtube')}\n")
|
||||
f.write(f"Extractor Key : {meta.get('extractor_key', 'Youtube')}\n")
|
||||
f.write("=" * 70 + "\n")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Transcript
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_snippet_text(snippet) -> str:
|
||||
"""Safely extract text from a transcript snippet (dict or object)."""
|
||||
if isinstance(snippet, dict):
|
||||
return snippet.get("text", "")
|
||||
return getattr(snippet, "text", "") or ""
|
||||
|
||||
|
||||
def _get_snippet_start(snippet) -> float:
|
||||
"""Safely extract start time from a transcript snippet (dict or object)."""
|
||||
if isinstance(snippet, dict):
|
||||
return snippet.get("start", 0.0)
|
||||
return getattr(snippet, "start", 0.0) or 0.0
|
||||
|
||||
|
||||
def _fetch_transcript(video_id: str):
|
||||
"""
|
||||
Fetch transcript snippets using youtube-transcript-api v1.x.
|
||||
v1.x API: YouTubeTranscriptApi().list(video_id) and .fetch(video_id, languages=[...])
|
||||
Returns (FetchedTranscript iterable, errors_list) — iterable may be None on failure.
|
||||
"""
|
||||
errors = []
|
||||
api = YouTubeTranscriptApi()
|
||||
|
||||
# Fast path: try direct fetch with preferred languages
|
||||
for lang_group in [["en", "en-US", "en-GB"], ["en-US"], ["en-GB"]]:
|
||||
try:
|
||||
fetched = api.fetch(video_id, languages=lang_group)
|
||||
return fetched, errors
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Slower path: list all available transcripts and pick the best
|
||||
try:
|
||||
transcript_list = api.list(video_id)
|
||||
except Exception as e:
|
||||
errors.append(f"list error: {e}")
|
||||
# Last-ditch: fetch with no language preference
|
||||
try:
|
||||
fetched = api.fetch(video_id)
|
||||
return fetched, errors
|
||||
except Exception as e2:
|
||||
errors.append(f"fetch error: {e2}")
|
||||
return None, errors
|
||||
|
||||
# Prefer any manual transcript
|
||||
all_transcripts = list(transcript_list)
|
||||
manual = [t for t in all_transcripts if not getattr(t, "is_generated", True)]
|
||||
auto = [t for t in all_transcripts if getattr(t, "is_generated", False)]
|
||||
|
||||
for t in (manual + auto):
|
||||
try:
|
||||
# Try fetching via the transcript object's own fetch method
|
||||
fetched = t.fetch()
|
||||
return fetched, errors
|
||||
except Exception as e:
|
||||
errors.append(f"t.fetch() error for {getattr(t, 'language_code','?')}: {e}")
|
||||
continue
|
||||
|
||||
errors.append("No fetchable transcripts found")
|
||||
return None, errors
|
||||
|
||||
|
||||
def extract_transcript(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""
|
||||
Download transcript using youtube-transcript-api.
|
||||
Falls back to auto-generated captions. Saves as .txt with timestamps.
|
||||
"""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__transcript.txt")
|
||||
ensure_dir(output_folder)
|
||||
|
||||
transcript, errors = _fetch_transcript(video_id)
|
||||
|
||||
if not transcript:
|
||||
raise RuntimeError(f"Could not retrieve transcript for {video_id}. Errors: {'; '.join(errors)}")
|
||||
|
||||
# Write formatted transcript
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"Transcript: {meta.get('title', video_id)}\n")
|
||||
f.write(f"Video ID: {video_id}\n")
|
||||
f.write(f"URL: {video_url(video_id)}\n")
|
||||
f.write("=" * 60 + "\n\n")
|
||||
for entry in transcript:
|
||||
start = _get_snippet_start(entry)
|
||||
text = _get_snippet_text(entry).replace("\n", " ")
|
||||
minutes, seconds = divmod(int(start), 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
ts = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" if hours else f"[{minutes:02d}:{seconds:02d}]"
|
||||
f.write(f"{ts} {text}\n")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def get_transcript_text(video_id: str) -> str:
|
||||
"""Get plain transcript text for summarization."""
|
||||
try:
|
||||
transcript, _ = _fetch_transcript(video_id)
|
||||
if transcript:
|
||||
return " ".join(_get_snippet_text(e) for e in transcript)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_summary(
|
||||
video_id: str,
|
||||
meta: Dict,
|
||||
output_folder: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
) -> str:
|
||||
"""Generate a summary using OpenRouter LLM and save as .txt."""
|
||||
if not api_key:
|
||||
raise RuntimeError("OpenRouter API key is not configured. Set it in the Settings panel.")
|
||||
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__summary.txt")
|
||||
ensure_dir(output_folder)
|
||||
|
||||
# Get transcript text for summarization
|
||||
transcript_text = get_transcript_text(video_id)
|
||||
if not transcript_text:
|
||||
transcript_text = meta.get("description", "")
|
||||
if not transcript_text:
|
||||
raise RuntimeError(f"No transcript or description available for summarization of {video_id}")
|
||||
|
||||
# Truncate very long transcripts to ~12000 chars (token budget)
|
||||
max_chars = 12000
|
||||
if len(transcript_text) > max_chars:
|
||||
transcript_text = transcript_text[:max_chars] + "\n\n[Transcript truncated for length]"
|
||||
|
||||
client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=config.OPENROUTER_BASE_URL,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
f"Title: {title}\n"
|
||||
f"Video ID: {video_id}\n"
|
||||
f"URL: {video_url(video_id)}\n\n"
|
||||
f"Transcript:\n{transcript_text}"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model or config.OPENROUTER_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt or config.SUMMARY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
max_tokens=config.SUMMARY_MAX_TOKENS,
|
||||
)
|
||||
|
||||
summary_text = response.choices[0].message.content or ""
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"Summary: {title}\n")
|
||||
f.write(f"Video ID: {video_id}\n")
|
||||
f.write(f"URL: {video_url(video_id)}\n")
|
||||
f.write("=" * 60 + "\n\n")
|
||||
f.write(summary_text)
|
||||
f.write("\n")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Thumbnail
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_thumbnail(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""Download the highest-resolution thumbnail available."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
ensure_dir(output_folder)
|
||||
|
||||
# Build priority list of thumbnail URLs
|
||||
thumbnails = meta.get("thumbnails", [])
|
||||
# Sort by resolution (width * height), highest first
|
||||
thumbnails_sorted = sorted(
|
||||
[t for t in thumbnails if t.get("url")],
|
||||
key=lambda t: (t.get("width", 0) or 0) * (t.get("height", 0) or 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Fallback URLs
|
||||
fallback_urls = [
|
||||
f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/sddefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/default.jpg",
|
||||
]
|
||||
|
||||
candidate_urls = [t["url"] for t in thumbnails_sorted] + fallback_urls
|
||||
|
||||
for url in candidate_urls:
|
||||
try:
|
||||
response = requests.get(url, timeout=15)
|
||||
if response.status_code == 200 and len(response.content) > 1000:
|
||||
# Determine extension from content-type or URL
|
||||
ct = response.headers.get("content-type", "")
|
||||
if "webp" in ct or url.endswith(".webp"):
|
||||
ext = "webp"
|
||||
elif "png" in ct or url.endswith(".png"):
|
||||
ext = "png"
|
||||
else:
|
||||
ext = "jpg"
|
||||
file_path = os.path.join(output_folder, f"{base}__thumbnail.{ext}")
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
return file_path
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"Could not download thumbnail for {video_id}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: MP3
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mp3(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""Download audio and convert to MP3 using yt-dlp + ffmpeg."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
ensure_dir(output_folder)
|
||||
|
||||
ffmpeg_dir = _find_ffmpeg()
|
||||
|
||||
# yt-dlp will append extension itself; we use outtmpl without extension
|
||||
out_template = os.path.join(output_folder, f"{base}__audio")
|
||||
expected_path = os.path.join(output_folder, f"{base}__audio.mp3")
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"format": "bestaudio/best",
|
||||
"outtmpl": out_template,
|
||||
"postprocessors": [
|
||||
{
|
||||
"key": "FFmpegExtractAudio",
|
||||
"preferredcodec": "mp3",
|
||||
"preferredquality": "192",
|
||||
}
|
||||
],
|
||||
"ignoreerrors": False,
|
||||
}
|
||||
|
||||
if ffmpeg_dir:
|
||||
ydl_opts["ffmpeg_location"] = ffmpeg_dir
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([video_url(video_id)])
|
||||
|
||||
# Rename to standard naming convention
|
||||
if os.path.exists(expected_path):
|
||||
final_path = os.path.join(output_folder, f"{base}__mp3.mp3")
|
||||
if not os.path.exists(final_path):
|
||||
os.rename(expected_path, final_path)
|
||||
return final_path
|
||||
|
||||
# Search for any mp3 file created in this run
|
||||
for fname in os.listdir(output_folder):
|
||||
if fname.startswith(base) and fname.endswith(".mp3"):
|
||||
final_path = os.path.join(output_folder, f"{base}__mp3.mp3")
|
||||
src = os.path.join(output_folder, fname)
|
||||
if src != final_path and not os.path.exists(final_path):
|
||||
os.rename(src, final_path)
|
||||
return final_path
|
||||
|
||||
raise RuntimeError(f"MP3 file not found after download for {video_id}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: MP4
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_ffmpeg() -> Optional[str]:
|
||||
"""Find ffmpeg binary location."""
|
||||
# Common Windows locations
|
||||
candidates = [
|
||||
r"C:\ProgramData\chocolatey\bin",
|
||||
r"C:\ffmpeg\bin",
|
||||
r"C:\Program Files\ffmpeg\bin",
|
||||
r"C:\Program Files (x86)\ffmpeg\bin",
|
||||
]
|
||||
for folder in candidates:
|
||||
if os.path.isfile(os.path.join(folder, "ffmpeg.exe")):
|
||||
return folder
|
||||
# Try PATH
|
||||
import shutil
|
||||
found = shutil.which("ffmpeg")
|
||||
if found:
|
||||
return str(Path(found).parent)
|
||||
return None
|
||||
|
||||
|
||||
def extract_mp4(video_id: str, meta: Dict, output_folder: str, resolution: str = "best") -> str:
|
||||
"""Download video as MP4 using yt-dlp. Explicitly uses ffmpeg for merging."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
ensure_dir(output_folder)
|
||||
|
||||
ffmpeg_dir = _find_ffmpeg()
|
||||
|
||||
# Build format selector — always require vcodec to avoid audio-only streams
|
||||
if resolution == "best":
|
||||
fmt = (
|
||||
"bestvideo[vcodec^=avc1][ext=mp4]+bestaudio[ext=m4a]"
|
||||
"/bestvideo[ext=mp4]+bestaudio[ext=m4a]"
|
||||
"/bestvideo+bestaudio"
|
||||
"/best[vcodec!=none]"
|
||||
"/best"
|
||||
)
|
||||
else:
|
||||
fmt = (
|
||||
f"bestvideo[vcodec^=avc1][height<={resolution}][ext=mp4]+bestaudio[ext=m4a]"
|
||||
f"/bestvideo[height<={resolution}][ext=mp4]+bestaudio[ext=m4a]"
|
||||
f"/bestvideo[height<={resolution}]+bestaudio"
|
||||
f"/best[height<={resolution}][vcodec!=none]"
|
||||
f"/best[height<={resolution}]"
|
||||
f"/best[vcodec!=none]"
|
||||
f"/best"
|
||||
)
|
||||
|
||||
out_template = os.path.join(output_folder, f"{base}__mp4.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"format": fmt,
|
||||
"outtmpl": out_template,
|
||||
"merge_output_format": "mp4",
|
||||
"ignoreerrors": False,
|
||||
"postprocessors": [
|
||||
{"key": "FFmpegVideoConvertor", "preferedformat": "mp4"},
|
||||
],
|
||||
}
|
||||
|
||||
# Explicitly tell yt-dlp where ffmpeg is
|
||||
if ffmpeg_dir:
|
||||
ydl_opts["ffmpeg_location"] = ffmpeg_dir
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([video_url(video_id)])
|
||||
|
||||
# Find the downloaded file
|
||||
for fname in sorted(os.listdir(output_folder)):
|
||||
if fname.startswith(f"{base}__mp4") and fname.endswith(".mp4"):
|
||||
return os.path.join(output_folder, fname)
|
||||
|
||||
raise RuntimeError(f"MP4 file not found after download for {video_id}. ffmpeg_dir={ffmpeg_dir}")
|
||||
30
backend/requirements.txt
Normal file
30
backend/requirements.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
# Core
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
sse-starlette>=1.8.2
|
||||
python-multipart>=0.0.9
|
||||
|
||||
# YouTube downloading
|
||||
yt-dlp>=2024.3.10
|
||||
|
||||
# Transcripts
|
||||
youtube-transcript-api>=0.6.2
|
||||
|
||||
# HTTP / utilities
|
||||
requests>=2.31.0
|
||||
aiofiles>=23.2.1
|
||||
|
||||
# LLM (OpenRouter via OpenAI-compatible API)
|
||||
openai>=1.14.0
|
||||
|
||||
# Database
|
||||
# sqlite3 is built-in to Python
|
||||
|
||||
# MCP
|
||||
mcp>=1.0.0
|
||||
|
||||
# Misc
|
||||
python-slugify>=8.0.4
|
||||
pydantic>=2.6.0
|
||||
tqdm>=4.66.2
|
||||
BIN
backend/web/app.js
Normal file
BIN
backend/web/app.js
Normal file
Binary file not shown.
265
backend/web/index.html
Normal file
265
backend/web/index.html
Normal file
@@ -0,0 +1,265 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YouTube SIPOC Tool</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════════════════════════ HEADER ═══════════════════════════ -->
|
||||
<header class="app-header">
|
||||
<div class="app-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1V9.01a6.33 6.33 0 00-.79-.05 6.34 6.34 0 00-6.34 6.34 6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.33-6.34v-7a8.15 8.15 0 004.77 1.52v-3.4a4.85 4.85 0 01-1-.09z"/>
|
||||
</svg>
|
||||
<span class="app-title">YouTube <span>SIPOC</span></span>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<button class="nav-btn active" onclick="showView('jobs')" id="nav-jobs">Jobs</button>
|
||||
<button class="nav-btn" onclick="showView('new')" id="nav-new">+ New Job</button>
|
||||
<button class="nav-btn" onclick="showView('settings')" id="nav-settings">⚙ Settings</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="app-body">
|
||||
|
||||
<!-- ═══════════════════════ SIDEBAR ═══════════════════════ -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<button class="new-job-btn" onclick="showView('new')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Extraction Job
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-section" style="flex:1; overflow-y:auto; border-bottom:none;">
|
||||
<div class="sidebar-label">Recent Jobs</div>
|
||||
<div class="job-list" id="sidebar-job-list">
|
||||
<div class="text-muted" style="font-size:12px;padding:8px 4px;">No jobs yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══════════════════════ MAIN CONTENT ═══════════════════════ -->
|
||||
<main class="main-content">
|
||||
|
||||
<!-- ─── VIEW: NEW JOB ─── -->
|
||||
<div id="view-new" class="hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
||||
<div>
|
||||
<div class="card-title">New Extraction Job</div>
|
||||
<div class="card-subtitle">Configure YouTube source and what to extract</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Type -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Input Type</label>
|
||||
<div class="type-pills">
|
||||
<div class="type-pill selected" data-type="url" onclick="selectInputType('url')">🔗 Single URL</div>
|
||||
<div class="type-pill" data-type="playlist" onclick="selectInputType('playlist')">📋 Playlist</div>
|
||||
<div class="type-pill" data-type="handle" onclick="selectInputType('handle')">👤 Channel Handle</div>
|
||||
<div class="type-pill" data-type="csv" onclick="selectInputType('csv')">📄 CSV List</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Value -->
|
||||
<div class="form-group" id="input-url-group">
|
||||
<label class="form-label" id="input-label">YouTube URL</label>
|
||||
<input type="text" class="form-input" id="input-value"
|
||||
placeholder="https://www.youtube.com/watch?v=..." />
|
||||
</div>
|
||||
|
||||
<!-- CSV textarea (only shown for csv type) -->
|
||||
<div class="form-group hidden" id="input-csv-group">
|
||||
<label class="form-label">CSV / List of URLs (one per line or comma-separated)</label>
|
||||
<textarea class="form-textarea" id="input-csv-value" rows="5"
|
||||
placeholder="https://youtu.be/abc123 https://youtu.be/xyz456 dQw4w9WgXcQ"></textarea>
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<!-- Artifact Selection -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">What to Extract (select all that apply)</label>
|
||||
<div class="artifact-grid" id="artifact-grid">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<!-- Output Folder -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Output Folder</label>
|
||||
<div class="folder-display" id="folder-display" onclick="openFolderBrowser()">
|
||||
<span>📁</span>
|
||||
<span class="folder-path" id="folder-path-text">Click to choose a folder…</span>
|
||||
</div>
|
||||
<input type="hidden" id="output-folder" value="" />
|
||||
</div>
|
||||
|
||||
<!-- Resolution (shown only when mp4 selected) -->
|
||||
<div class="form-group hidden" id="resolution-group">
|
||||
<label class="form-label">MP4 Resolution</label>
|
||||
<select class="form-select" id="resolution">
|
||||
<option value="best">Best Available</option>
|
||||
<option value="2160">4K (2160p)</option>
|
||||
<option value="1440">2K (1440p)</option>
|
||||
<option value="1080" selected>1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="480">480p</option>
|
||||
<option value="360">360p</option>
|
||||
<option value="240">240p</option>
|
||||
<option value="144">144p</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Bot Avoidance Delays -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Random Delay Between Downloads (seconds, for bot detection avoidance)</label>
|
||||
<div class="range-row">
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label" style="font-size:11px">Min Delay</label>
|
||||
<input type="number" class="form-input" id="delay-min" value="2" min="0" max="60" step="0.5" style="width:100px" />
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label" style="font-size:11px">Max Delay</label>
|
||||
<input type="number" class="form-input" id="delay-max" value="7" min="0" max="120" step="0.5" style="width:100px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<!-- API Settings -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">OpenRouter API Key <span class="text-muted">(required for summaries)</span></label>
|
||||
<input type="password" class="form-input" id="api-key"
|
||||
placeholder="sk-or-v1-..." autocomplete="off" />
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
|
||||
Not stored — only used for this session's summary requests
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">OpenRouter Model</label>
|
||||
<input type="text" class="form-input" id="or-model"
|
||||
value="openai/gpt-4o-mini"
|
||||
placeholder="openai/gpt-4o-mini" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Summary Prompt <span class="text-muted">(leave blank for default)</span></label>
|
||||
<textarea class="form-textarea" id="summary-prompt" rows="3"
|
||||
placeholder="Custom system prompt for LLM summarization (optional)…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" onclick="createJob()" id="create-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="5 12 12 5 19 12"/><polyline points="12 19 12 5"/></svg>
|
||||
Start Extraction
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick="showView('jobs')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── VIEW: JOB DETAIL ─── -->
|
||||
<div id="view-jobs">
|
||||
<div id="job-detail">
|
||||
<!-- Welcome / empty state -->
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🎬</div>
|
||||
<div class="empty-state-title">YouTube SIPOC Tool</div>
|
||||
<div class="empty-state-body">
|
||||
Extract titles, stats, transcripts, summaries, thumbnails, MP3, and MP4 from YouTube videos.<br/><br/>
|
||||
Click <strong>+ New Job</strong> to get started.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── VIEW: SETTINGS ─── -->
|
||||
<div id="view-settings" class="hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">⚙ Settings & Defaults</div>
|
||||
<div class="card-subtitle">These defaults are used when creating new jobs</div>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default Delay Min (s)</label>
|
||||
<input type="number" class="form-input" id="s-delay-min" value="2" min="0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default Delay Max (s)</label>
|
||||
<input type="number" class="form-input" id="s-delay-max" value="7" min="0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default MP4 Resolution</label>
|
||||
<select class="form-select" id="s-resolution">
|
||||
<option value="best">Best</option>
|
||||
<option value="1080" selected>1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="480">480p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default OpenRouter Model</label>
|
||||
<input type="text" class="form-input" id="s-model" value="openai/gpt-4o-mini" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default Summary Prompt</label>
|
||||
<textarea class="form-textarea" id="s-prompt" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-blue" onclick="saveSettings()">Save Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">🔌 MCP Server Registration</div>
|
||||
<div class="card-subtitle">Add this to your MCP settings to use with AI agents</div>
|
||||
</div>
|
||||
<pre id="mcp-config-snippet" style="background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:14px;font-size:12px;color:var(--text-dim);overflow-x:auto;white-space:pre-wrap;word-break:break-all;"></pre>
|
||||
<div class="mt-8">
|
||||
<button class="btn btn-outline btn-sm" onclick="copyMcpConfig()">📋 Copy to Clipboard</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════ FOLDER BROWSER MODAL ═══════════════════════ -->
|
||||
<div class="modal-overlay hidden" id="folder-modal">
|
||||
<div class="modal" style="max-width:600px">
|
||||
<div class="modal-title">📁 Choose Output Folder</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div class="input-with-btn">
|
||||
<input type="text" class="form-input" id="folder-modal-path" placeholder="Type path or browse below…" />
|
||||
<button class="btn btn-blue btn-sm" onclick="navigateToTyped()">Go</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="createNewFolder()">+ New Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-current" id="folder-current-path"></div>
|
||||
<div class="folder-browser" id="folder-browser-list"></div>
|
||||
<div class="btn-row mt-16">
|
||||
<button class="btn btn-green" onclick="selectCurrentFolder()">✔ Select This Folder</button>
|
||||
<button class="btn btn-outline" onclick="closeFolderModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════ TOAST CONTAINER ═══════════════════════ -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
778
backend/web/style.css
Normal file
778
backend/web/style.css
Normal file
@@ -0,0 +1,778 @@
|
||||
/* ============================================================
|
||||
YouTube SIPOC Tool - Dark Mode UI
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--bg2: #1a1a1a;
|
||||
--bg3: #242424;
|
||||
--bg4: #2e2e2e;
|
||||
--border: #3a3a3a;
|
||||
--text: #e8e8e8;
|
||||
--text-dim: #888;
|
||||
--text-muted: #555;
|
||||
--accent: #ff4444;
|
||||
--accent2: #cc2222;
|
||||
--blue: #4a9eff;
|
||||
--green: #22cc66;
|
||||
--yellow: #ffcc00;
|
||||
--orange: #ff8822;
|
||||
--purple: #9966ff;
|
||||
--red: #ff4444;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────────── */
|
||||
|
||||
.app-header {
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-logo svg { color: var(--accent); }
|
||||
|
||||
.app-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.app-title span { color: var(--accent); }
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--border); }
|
||||
.nav-btn.active { background: var(--bg3); color: var(--text); border-color: var(--border); }
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.job-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.job-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.job-item:hover { background: var(--bg3); }
|
||||
.job-item.active { background: var(--bg3); border-color: var(--border); }
|
||||
|
||||
.job-item-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.job-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.new-job-btn {
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.new-job-btn:hover { background: var(--accent2); }
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
/* ── Cards & Panels ──────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Form Controls ────────────────────────────────────────── */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus { border-color: var(--blue); }
|
||||
.form-textarea { resize: vertical; min-height: 80px; }
|
||||
.form-input.error { border-color: var(--red); }
|
||||
|
||||
.input-with-btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.input-with-btn .form-input { flex: 1; }
|
||||
|
||||
/* Type pills */
|
||||
.type-pills {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.type-pill:hover { border-color: var(--blue); color: var(--text); }
|
||||
.type-pill.selected { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||
|
||||
/* Artifact checkboxes */
|
||||
.artifact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.artifact-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.artifact-check:hover { border-color: var(--blue); }
|
||||
.artifact-check.checked { border-color: var(--green); background: rgba(34,204,102,0.08); }
|
||||
.artifact-check input { display: none; }
|
||||
|
||||
.artifact-icon { font-size: 16px; width: 20px; text-align: center; }
|
||||
.artifact-label { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||
|
||||
/* Range inputs */
|
||||
.range-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.range-row .form-group { flex: 1; }
|
||||
|
||||
/* Folder picker */
|
||||
.folder-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
min-height: 36px;
|
||||
}
|
||||
.folder-display:hover { border-color: var(--blue); }
|
||||
.folder-display .folder-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--accent2); }
|
||||
|
||||
.btn-blue { background: var(--blue); color: #fff; }
|
||||
.btn-blue:hover:not(:disabled) { background: #3a8ee0; }
|
||||
|
||||
.btn-green { background: var(--green); color: #000; }
|
||||
.btn-green:hover:not(:disabled) { background: #1ab055; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.btn-outline:hover:not(:disabled) { border-color: var(--text-dim); color: var(--text); }
|
||||
|
||||
.btn-danger { background: rgba(255,68,68,0.15); border-color: var(--red); color: var(--red); }
|
||||
.btn-danger:hover:not(:disabled) { background: rgba(255,68,68,0.25); }
|
||||
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Status Badges ────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.badge-running { background: rgba(74,158,255,0.15); color: var(--blue); border: 1px solid rgba(74,158,255,0.3); }
|
||||
.badge-done { background: rgba(34,204,102,0.15); color: var(--green); border: 1px solid rgba(34,204,102,0.3); }
|
||||
.badge-done_with_errors { background: rgba(255,136,34,0.15); color: var(--orange); border: 1px solid rgba(255,136,34,0.3); }
|
||||
.badge-failed { background: rgba(255,68,68,0.15); color: var(--red); border: 1px solid rgba(255,68,68,0.3); }
|
||||
.badge-paused { background: rgba(255,204,0,0.15); color: var(--yellow); border: 1px solid rgba(255,204,0,0.3); }
|
||||
.badge-stopped { background: rgba(136,136,136,0.15); color: var(--text-dim); border: 1px solid var(--border); }
|
||||
.badge-pending { background: rgba(153,102,255,0.15); color: var(--purple); border: 1px solid rgba(153,102,255,0.3); }
|
||||
|
||||
/* Artifact status */
|
||||
.art-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.art-dot-done { background: var(--green); }
|
||||
.art-dot-failed { background: var(--red); box-shadow: 0 0 4px var(--red); }
|
||||
.art-dot-running { background: var(--blue); animation: pulse 1s infinite; }
|
||||
.art-dot-pending { background: var(--border); }
|
||||
.art-dot-skipped { background: var(--text-muted); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── Progress ─────────────────────────────────────────────── */
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg4);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--blue);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.progress-fill.green { background: var(--green); }
|
||||
.progress-fill.yellow { background: var(--yellow); }
|
||||
.progress-fill.red { background: var(--red); }
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Video Table ──────────────────────────────────────────── */
|
||||
|
||||
.video-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.video-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-table th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg3);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.video-table tr:last-child td { border-bottom: none; }
|
||||
.video-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
.video-table tr.active-row td { background: rgba(74,158,255,0.06); }
|
||||
|
||||
.video-title-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-id-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.artifact-pills {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.art-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.art-pill-done { border-color: rgba(34,204,102,0.4); background: rgba(34,204,102,0.08); color: var(--green); }
|
||||
.art-pill-failed { border-color: rgba(255,68,68,0.4); background: rgba(255,68,68,0.08); color: var(--red); }
|
||||
.art-pill-running { border-color: rgba(74,158,255,0.4); background: rgba(74,158,255,0.08); color: var(--blue); }
|
||||
.art-pill-pending { color: var(--text-muted); }
|
||||
|
||||
/* ── Log Panel ────────────────────────────────────────────── */
|
||||
|
||||
.log-panel {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-ts { color: var(--text-muted); flex-shrink: 0; }
|
||||
.log-level { flex-shrink: 0; font-weight: 700; min-width: 40px; }
|
||||
.log-level-INFO { color: var(--blue); }
|
||||
.log-level-WARN { color: var(--yellow); }
|
||||
.log-level-ERROR { color: var(--red); }
|
||||
.log-level-DEBUG { color: var(--text-muted); }
|
||||
.log-msg { color: var(--text-dim); word-break: break-all; }
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Folder browser */
|
||||
.folder-browser {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.1s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.folder-entry:last-child { border-bottom: none; }
|
||||
.folder-entry:hover { background: var(--bg4); }
|
||||
.folder-entry.is-file { color: var(--text-muted); cursor: default; }
|
||||
.folder-entry .entry-icon { width: 18px; text-align: center; font-size: 15px; }
|
||||
|
||||
.folder-current {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg4);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Toast Notifications ─────────────────────────────────── */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
min-width: 260px;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
animation: slide-in 0.2s ease;
|
||||
}
|
||||
.toast.success { border-left: 3px solid var(--green); }
|
||||
.toast.error { border-left: 3px solid var(--red); }
|
||||
.toast.info { border-left: 3px solid var(--blue); }
|
||||
.toast-msg { font-size: 13px; color: var(--text); flex: 1; }
|
||||
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(20px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Settings ─────────────────────────────────────────────── */
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 600px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Empty States ─────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-state-icon { font-size: 48px; }
|
||||
.empty-state-title { font-size: 16px; font-weight: 700; color: var(--text-dim); }
|
||||
.empty-state-body { font-size: 13px; max-width: 360px; }
|
||||
|
||||
/* ── Misc ─────────────────────────────────────────────────── */
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-green { color: var(--green); }
|
||||
.text-red { color: var(--red); }
|
||||
.text-blue { color: var(--blue); }
|
||||
.text-yellow { color: var(--yellow); }
|
||||
|
||||
.monospace { font-family: var(--font-mono); }
|
||||
|
||||
.flex-row { display: flex; align-items: center; gap: 8px; }
|
||||
.flex-1 { flex: 1; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Job Control Bar ──────────────────────────────────────── */
|
||||
|
||||
.job-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.current-video-info {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border); }
|
||||
|
||||
/* View tabs */
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.view-tab {
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.view-tab:hover { color: var(--text); }
|
||||
.view-tab.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||
507
backend/worker.py
Normal file
507
backend/worker.py
Normal file
@@ -0,0 +1,507 @@
|
||||
"""
|
||||
worker.py - Background job pipeline for YouTube SIPOC Tool
|
||||
Handles pause/stop/resume, per-artifact saving, duplicate skipping,
|
||||
random delays, retry logic, and resume-on-failure.
|
||||
"""
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Callable, Optional
|
||||
|
||||
import database as db
|
||||
import extractors
|
||||
import config
|
||||
|
||||
logger = logging.getLogger("worker")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job Control Registry
|
||||
# Stores threading.Event objects per job_id for pause/stop/skip signals.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_job_controls: Dict[str, Dict] = {}
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_controls(job_id: str) -> Dict:
|
||||
with _registry_lock:
|
||||
if job_id not in _job_controls:
|
||||
_job_controls[job_id] = {
|
||||
"pause": threading.Event(), # set = paused
|
||||
"stop": threading.Event(), # set = stop requested
|
||||
"skip": threading.Event(), # set = skip current video
|
||||
}
|
||||
return _job_controls[job_id]
|
||||
|
||||
|
||||
def _cleanup_controls(job_id: str):
|
||||
with _registry_lock:
|
||||
_job_controls.pop(job_id, None)
|
||||
|
||||
|
||||
def pause_job(job_id: str):
|
||||
_get_controls(job_id)["pause"].set()
|
||||
db.update_job_status(job_id, "paused")
|
||||
db.add_log(job_id, "Job paused by user.", level="INFO")
|
||||
|
||||
|
||||
def resume_job(job_id: str):
|
||||
_get_controls(job_id)["pause"].clear()
|
||||
db.update_job_status(job_id, "running")
|
||||
db.add_log(job_id, "Job resumed by user.", level="INFO")
|
||||
|
||||
|
||||
def stop_job(job_id: str):
|
||||
_get_controls(job_id)["stop"].set()
|
||||
_get_controls(job_id)["pause"].clear() # Unpause so thread can exit
|
||||
db.update_job_status(job_id, "stopped")
|
||||
db.add_log(job_id, "Job stopped by user.", level="INFO")
|
||||
|
||||
|
||||
def skip_video(job_id: str):
|
||||
"""Signal the worker to skip the current video and move to the next."""
|
||||
_get_controls(job_id)["skip"].set()
|
||||
db.add_log(job_id, "Skip requested for current video.", level="INFO")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SSE Event Emitter
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class EventEmitter:
|
||||
"""Thread-safe event queue for Server-Sent Events."""
|
||||
|
||||
def __init__(self):
|
||||
self._listeners: Dict[str, list] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def subscribe(self, job_id: str) -> "EventQueue":
|
||||
queue = EventQueue()
|
||||
with self._lock:
|
||||
if job_id not in self._listeners:
|
||||
self._listeners[job_id] = []
|
||||
self._listeners[job_id].append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, job_id: str, queue: "EventQueue"):
|
||||
with self._lock:
|
||||
if job_id in self._listeners:
|
||||
try:
|
||||
self._listeners[job_id].remove(queue)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def emit(self, job_id: str, event_type: str, data: dict):
|
||||
with self._lock:
|
||||
listeners = list(self._listeners.get(job_id, []))
|
||||
for q in listeners:
|
||||
q.put(event_type, data)
|
||||
|
||||
|
||||
class EventQueue:
|
||||
"""Per-subscriber event queue with thread-safe get."""
|
||||
|
||||
def __init__(self):
|
||||
import queue
|
||||
self._q = queue.Queue(maxsize=500)
|
||||
|
||||
def put(self, event_type: str, data: dict):
|
||||
try:
|
||||
self._q.put_nowait({"type": event_type, "data": data})
|
||||
except Exception:
|
||||
pass # Drop events if queue is full
|
||||
|
||||
def get(self, timeout: float = 30.0):
|
||||
try:
|
||||
return self._q.get(timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Global emitter instance
|
||||
emitter = EventEmitter()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Worker Thread Entry Point
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_job(job_id: str):
|
||||
"""
|
||||
Main worker function. Runs in a background thread.
|
||||
Resolves inputs, iterates videos, processes each artifact,
|
||||
emits SSE events, handles pause/stop/skip/retry.
|
||||
"""
|
||||
controls = _get_controls(job_id)
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
logger.error(f"Job {job_id} not found in database.")
|
||||
return
|
||||
|
||||
db.update_job_status(job_id, "running")
|
||||
_emit(job_id, "job_status", {"job_id": job_id, "status": "running"})
|
||||
db.add_log(job_id, "Job started.", level="INFO")
|
||||
|
||||
try:
|
||||
# ── Step 1: Resolve input to video list ──────────────────────────────
|
||||
_emit(job_id, "progress", {"message": "Resolving input URLs…"})
|
||||
db.add_log(job_id, f"Resolving input: type={job['input_type']}", level="INFO")
|
||||
|
||||
try:
|
||||
videos = extractors.resolve_input(job["input_type"], job["input_value"])
|
||||
except Exception as e:
|
||||
db.add_log(job_id, f"Failed to resolve input: {e}", level="ERROR")
|
||||
db.update_job_status(job_id, "failed")
|
||||
_emit(job_id, "job_status", {"job_id": job_id, "status": "failed", "error": str(e)})
|
||||
return
|
||||
|
||||
if not videos:
|
||||
db.add_log(job_id, "No videos found for input.", level="WARN")
|
||||
db.update_job_status(job_id, "done")
|
||||
_emit(job_id, "job_status", {"job_id": job_id, "status": "done"})
|
||||
return
|
||||
|
||||
# Deduplicate by video_id (preserve order)
|
||||
seen = set()
|
||||
unique_videos = []
|
||||
for v in videos:
|
||||
if v["video_id"] not in seen:
|
||||
seen.add(v["video_id"])
|
||||
unique_videos.append(v)
|
||||
videos = unique_videos
|
||||
|
||||
# ── Step 2: Register videos in DB ────────────────────────────────────
|
||||
for i, v in enumerate(videos):
|
||||
db.upsert_video(job_id, v["video_id"], v["url"], v.get("title", ""), i)
|
||||
db.update_job_progress(job_id, len(videos), 0)
|
||||
_emit(job_id, "video_list", {
|
||||
"job_id": job_id,
|
||||
"total": len(videos),
|
||||
"videos": [{"video_id": v["video_id"], "title": v.get("title", ""), "position": i}
|
||||
for i, v in enumerate(videos)]
|
||||
})
|
||||
db.add_log(job_id, f"Found {len(videos)} unique videos.", level="INFO")
|
||||
|
||||
# ── Step 3: Process each video ────────────────────────────────────────
|
||||
selections = job["selections"]
|
||||
done_count = 0
|
||||
|
||||
for idx, video in enumerate(videos):
|
||||
# Check stop
|
||||
if controls["stop"].is_set():
|
||||
db.add_log(job_id, "Job stopped before processing video.", level="INFO")
|
||||
break
|
||||
|
||||
# Handle pause (blocking)
|
||||
_wait_if_paused(controls)
|
||||
if controls["stop"].is_set():
|
||||
break
|
||||
|
||||
# Reset skip flag for this video
|
||||
controls["skip"].clear()
|
||||
|
||||
video_id = video["video_id"]
|
||||
db.add_log(job_id, f"Processing video {idx+1}/{len(videos)}: {video_id}", level="INFO")
|
||||
_emit(job_id, "video_start", {"job_id": job_id, "video_id": video_id, "position": idx})
|
||||
|
||||
# ── Step 3a: Fetch metadata (once per video) ──────────────────
|
||||
meta = {}
|
||||
try:
|
||||
_emit(job_id, "artifact_start", {
|
||||
"job_id": job_id, "video_id": video_id, "artifact_type": "metadata"
|
||||
})
|
||||
meta = extractors.fetch_metadata(video_id)
|
||||
title = meta.get("title", "")
|
||||
if title:
|
||||
db.update_video_title(job_id, video_id, title)
|
||||
_emit(job_id, "video_title", {"job_id": job_id, "video_id": video_id, "title": title})
|
||||
db.add_log(job_id, f"Metadata fetched for {video_id}: {title or '(no title)'}", level="INFO")
|
||||
except Exception as e:
|
||||
db.add_log(job_id, f"Failed to fetch metadata for {video_id}: {e}", level="ERROR")
|
||||
_emit(job_id, "video_error", {"job_id": job_id, "video_id": video_id, "error": str(e)})
|
||||
db.update_video_status(job_id, video_id, "failed")
|
||||
continue
|
||||
|
||||
# ── Step 3b: Initialize artifact records ──────────────────────
|
||||
for artifact_type in selections:
|
||||
db.upsert_artifact(job_id, video_id, artifact_type)
|
||||
|
||||
# ── Step 3c: Process each selected artifact ───────────────────
|
||||
video_failed = False
|
||||
for artifact_type in selections:
|
||||
# Check stop or skip
|
||||
if controls["stop"].is_set():
|
||||
break
|
||||
if controls["skip"].is_set():
|
||||
db.add_log(job_id, f"Skipping remaining artifacts for {video_id} (skip requested).", level="INFO")
|
||||
break
|
||||
|
||||
# Handle pause
|
||||
_wait_if_paused(controls)
|
||||
if controls["stop"].is_set():
|
||||
break
|
||||
|
||||
# Skip if already done
|
||||
if db.artifact_is_done(job_id, video_id, artifact_type):
|
||||
db.add_log(job_id, f"[SKIP-DUP] {artifact_type} for {video_id} already done.", level="INFO")
|
||||
_emit(job_id, "artifact_skip", {
|
||||
"job_id": job_id, "video_id": video_id, "artifact_type": artifact_type,
|
||||
"reason": "already_done"
|
||||
})
|
||||
continue
|
||||
|
||||
# Process with retry
|
||||
success = _process_artifact_with_retry(
|
||||
job_id=job_id,
|
||||
video_id=video_id,
|
||||
artifact_type=artifact_type,
|
||||
meta=meta,
|
||||
job=job,
|
||||
controls=controls,
|
||||
)
|
||||
if not success:
|
||||
video_failed = True
|
||||
|
||||
# Random delay between artifacts (bot avoidance)
|
||||
if not controls["stop"].is_set() and not controls["skip"].is_set():
|
||||
delay = random.uniform(job["delay_min"], job["delay_max"])
|
||||
db.add_log(job_id, f"Waiting {delay:.1f}s before next artifact…", level="DEBUG")
|
||||
_interruptible_sleep(delay, controls)
|
||||
|
||||
# ── Step 3d: Mark video done/failed ──────────────────────────
|
||||
if not controls["stop"].is_set():
|
||||
status = "failed" if video_failed else "done"
|
||||
db.update_video_status(job_id, video_id, status)
|
||||
done_count += 1
|
||||
db.update_job_progress(job_id, len(videos), done_count)
|
||||
_emit(job_id, "video_done", {
|
||||
"job_id": job_id,
|
||||
"video_id": video_id,
|
||||
"status": status,
|
||||
"done": done_count,
|
||||
"total": len(videos),
|
||||
})
|
||||
|
||||
# Random delay between videos
|
||||
if idx < len(videos) - 1 and not controls["stop"].is_set():
|
||||
delay = random.uniform(job["delay_min"], job["delay_max"])
|
||||
db.add_log(job_id, f"Waiting {delay:.1f}s before next video…", level="DEBUG")
|
||||
_interruptible_sleep(delay, controls)
|
||||
|
||||
# ── Step 4: Finalize job ──────────────────────────────────────────────
|
||||
if controls["stop"].is_set():
|
||||
final_status = "stopped"
|
||||
else:
|
||||
stats = db.get_job_stats(job_id)
|
||||
final_status = "done" if stats["artifact_failed"] == 0 else "done_with_errors"
|
||||
|
||||
db.update_job_status(job_id, final_status)
|
||||
stats = db.get_job_stats(job_id)
|
||||
db.add_log(job_id, f"Job finished with status: {final_status}. Stats: {stats}", level="INFO")
|
||||
_emit(job_id, "job_status", {"job_id": job_id, "status": final_status, "stats": stats})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error in job {job_id}: {e}")
|
||||
db.add_log(job_id, f"Unexpected error: {e}", level="ERROR")
|
||||
db.update_job_status(job_id, "failed")
|
||||
_emit(job_id, "job_status", {"job_id": job_id, "status": "failed", "error": str(e)})
|
||||
finally:
|
||||
_cleanup_controls(job_id)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact Processing with Retry
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _process_artifact_with_retry(
|
||||
job_id: str,
|
||||
video_id: str,
|
||||
artifact_type: str,
|
||||
meta: Dict,
|
||||
job: Dict,
|
||||
controls: Dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Attempt to process one artifact, retrying up to MAX_RETRIES times.
|
||||
Returns True on success, False on final failure.
|
||||
"""
|
||||
max_retries = config.MAX_RETRIES
|
||||
output_folder = job["output_folder"]
|
||||
resolution = job.get("resolution", "best")
|
||||
api_key = job.get("openrouter_model", "") # model field
|
||||
# We store the API key in the job separately; retrieve from job dict
|
||||
# (api_key is set in the job via the POST body - see api_server.py)
|
||||
openrouter_api_key = job.get("_openrouter_api_key", "") # injected by api_server
|
||||
openrouter_model = job.get("openrouter_model", config.OPENROUTER_MODEL)
|
||||
summary_prompt = job.get("summary_prompt", config.SUMMARY_SYSTEM_PROMPT)
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
if controls["stop"].is_set() or controls["skip"].is_set():
|
||||
return False
|
||||
|
||||
db.mark_artifact_started(job_id, video_id, artifact_type)
|
||||
_emit(job_id, "artifact_start", {
|
||||
"job_id": job_id,
|
||||
"video_id": video_id,
|
||||
"artifact_type": artifact_type,
|
||||
"attempt": attempt,
|
||||
})
|
||||
db.add_log(job_id, f"[{artifact_type}] Starting (attempt {attempt}/{max_retries})", level="INFO",
|
||||
video_id=video_id, artifact_type=artifact_type)
|
||||
|
||||
try:
|
||||
file_path = _run_extractor(
|
||||
artifact_type=artifact_type,
|
||||
video_id=video_id,
|
||||
meta=meta,
|
||||
output_folder=output_folder,
|
||||
resolution=resolution,
|
||||
openrouter_api_key=openrouter_api_key,
|
||||
openrouter_model=openrouter_model,
|
||||
summary_prompt=summary_prompt,
|
||||
)
|
||||
db.update_artifact_status(job_id, video_id, artifact_type, "done", file_path=file_path)
|
||||
db.add_log(job_id, f"[{artifact_type}] Done → {file_path}", level="INFO",
|
||||
video_id=video_id, artifact_type=artifact_type)
|
||||
_emit(job_id, "artifact_done", {
|
||||
"job_id": job_id,
|
||||
"video_id": video_id,
|
||||
"artifact_type": artifact_type,
|
||||
"file_path": file_path,
|
||||
})
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
logger.warning(f"[{job_id}][{video_id}][{artifact_type}] Attempt {attempt} failed: {e}")
|
||||
db.add_log(job_id, f"[{artifact_type}] Attempt {attempt} failed: {e}", level="WARN",
|
||||
video_id=video_id, artifact_type=artifact_type)
|
||||
|
||||
if attempt < max_retries:
|
||||
# Exponential backoff between retries
|
||||
backoff = min(2 ** attempt * 3, 30) + random.uniform(0, 2)
|
||||
db.add_log(job_id, f"[{artifact_type}] Retry in {backoff:.1f}s…", level="INFO",
|
||||
video_id=video_id, artifact_type=artifact_type)
|
||||
_interruptible_sleep(backoff, controls)
|
||||
else:
|
||||
db.update_artifact_status(
|
||||
job_id, video_id, artifact_type, "failed", error_msg=err_msg
|
||||
)
|
||||
db.add_log(job_id, f"[{artifact_type}] FAILED after {max_retries} attempts: {e}", level="ERROR",
|
||||
video_id=video_id, artifact_type=artifact_type)
|
||||
_emit(job_id, "artifact_failed", {
|
||||
"job_id": job_id,
|
||||
"video_id": video_id,
|
||||
"artifact_type": artifact_type,
|
||||
"error": err_msg,
|
||||
"attempts": max_retries,
|
||||
})
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _run_extractor(
|
||||
artifact_type: str,
|
||||
video_id: str,
|
||||
meta: Dict,
|
||||
output_folder: str,
|
||||
resolution: str,
|
||||
openrouter_api_key: str,
|
||||
openrouter_model: str,
|
||||
summary_prompt: str,
|
||||
) -> str:
|
||||
"""Dispatch to the correct extractor function."""
|
||||
if artifact_type == "title":
|
||||
file_path, _ = extractors.extract_title(video_id, meta, output_folder)
|
||||
return file_path
|
||||
elif artifact_type == "stats":
|
||||
return extractors.extract_stats(video_id, meta, output_folder)
|
||||
elif artifact_type == "transcript":
|
||||
return extractors.extract_transcript(video_id, meta, output_folder)
|
||||
elif artifact_type == "summary":
|
||||
return extractors.extract_summary(
|
||||
video_id, meta, output_folder,
|
||||
api_key=openrouter_api_key,
|
||||
model=openrouter_model,
|
||||
system_prompt=summary_prompt,
|
||||
)
|
||||
elif artifact_type == "thumbnail":
|
||||
return extractors.extract_thumbnail(video_id, meta, output_folder)
|
||||
elif artifact_type == "mp3":
|
||||
return extractors.extract_mp3(video_id, meta, output_folder)
|
||||
elif artifact_type == "mp4":
|
||||
return extractors.extract_mp4(video_id, meta, output_folder, resolution=resolution)
|
||||
else:
|
||||
raise ValueError(f"Unknown artifact type: {artifact_type}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _emit(job_id: str, event_type: str, data: dict):
|
||||
"""Emit an SSE event."""
|
||||
emitter.emit(job_id, event_type, data)
|
||||
|
||||
|
||||
def _wait_if_paused(controls: Dict):
|
||||
"""Block worker thread while paused, check every 0.5s."""
|
||||
while controls["pause"].is_set() and not controls["stop"].is_set():
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _interruptible_sleep(seconds: float, controls: Dict):
|
||||
"""Sleep for `seconds` but wake early if stop/skip is set."""
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
if controls["stop"].is_set() or controls["skip"].is_set():
|
||||
return
|
||||
time.sleep(min(0.3, end - time.monotonic()))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job Launcher
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def launch_job(job_id: str, openrouter_api_key: str = ""):
|
||||
"""
|
||||
Launch job in a daemon background thread.
|
||||
openrouter_api_key is injected at runtime (not stored in DB for security).
|
||||
"""
|
||||
# Inject API key into job dict via a side channel stored in controls
|
||||
controls = _get_controls(job_id)
|
||||
controls["_openrouter_api_key"] = openrouter_api_key
|
||||
|
||||
# Patch the run_job function to use the injected key
|
||||
def _patched_run():
|
||||
job = db.get_job(job_id)
|
||||
if job:
|
||||
job["_openrouter_api_key"] = openrouter_api_key
|
||||
run_job_with_key(job_id, openrouter_api_key)
|
||||
|
||||
t = threading.Thread(target=_patched_run, daemon=True, name=f"job-{job_id[:8]}")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def run_job_with_key(job_id: str, openrouter_api_key: str):
|
||||
"""Wrapper that injects the API key into job processing."""
|
||||
# Monkey-patch the job dict to include the API key during this run
|
||||
original_get_job = db.get_job
|
||||
|
||||
def patched_get_job(jid):
|
||||
result = original_get_job(jid)
|
||||
if result and jid == job_id:
|
||||
result["_openrouter_api_key"] = openrouter_api_key
|
||||
return result
|
||||
|
||||
# Temporarily patch db.get_job for this thread
|
||||
import database
|
||||
_orig = database.get_job
|
||||
database.get_job = patched_get_job
|
||||
try:
|
||||
run_job(job_id)
|
||||
finally:
|
||||
database.get_job = _orig
|
||||
BIN
backend/youtube_sipoc.db
Normal file
BIN
backend/youtube_sipoc.db
Normal file
Binary file not shown.
Reference in New Issue
Block a user