Upload 15 files
Browse files- Dockerfile +1 -1
- app.py +492 -0
- pipeline/__init__.py +1 -0
- pipeline/orchestrator.py +599 -0
- pipeline/worker.py +741 -0
- worker/db_worker/get_apis.py +23 -0
- worker/db_worker/get_links.py +8 -0
- worker/hf_uploader.py +74 -0
- worker/models_worker/call_model.py +117 -0
- worker/models_worker/video_worker.py +87 -0
- worker/youtube_worker/audio_downloader.py +67 -0
- worker/youtube_worker/audio_worker/audio_splitter.py +153 -0
- worker/youtube_worker/downloader.py +98 -0
- worker/youtube_worker/video_worker/splitter.py +108 -0
Dockerfile
CHANGED
|
@@ -23,4 +23,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|
| 23 |
EXPOSE 7860
|
| 24 |
|
| 25 |
# Command to run on start
|
| 26 |
-
CMD ["python", "
|
|
|
|
| 23 |
EXPOSE 7860
|
| 24 |
|
| 25 |
# Command to run on start
|
| 26 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import sqlite3
|
| 3 |
+
import requests
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
def fetch_data(db_path, limit_links=None):
|
| 7 |
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
| 8 |
+
if not os.path.exists(db_path):
|
| 9 |
+
# We can either create it or return empty. I will let sqlite create it if it doesn't exist.
|
| 10 |
+
pass
|
| 11 |
+
conn = sqlite3.connect(db_path)
|
| 12 |
+
cursor = conn.cursor()
|
| 13 |
+
|
| 14 |
+
cursor.execute('''CREATE TABLE IF NOT EXISTS links (
|
| 15 |
+
id INTEGER PRIMARY KEY,
|
| 16 |
+
url TEXT,
|
| 17 |
+
status TEXT DEFAULT 'pending'
|
| 18 |
+
)''')
|
| 19 |
+
try:
|
| 20 |
+
cursor.execute("SELECT api_key FROM accounts")
|
| 21 |
+
api_keys = [row[0] for row in cursor.fetchall() if row[0]]
|
| 22 |
+
except Exception as e:
|
| 23 |
+
api_keys = []
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
query = "SELECT id, url FROM links WHERE status='pending'"
|
| 27 |
+
if limit_links:
|
| 28 |
+
query += f" LIMIT {limit_links}"
|
| 29 |
+
|
| 30 |
+
cursor.execute(query)
|
| 31 |
+
link_records = cursor.fetchall()
|
| 32 |
+
links = [row[1] for row in link_records if row[1]]
|
| 33 |
+
|
| 34 |
+
if link_records:
|
| 35 |
+
ids = [str(row[0]) for row in link_records]
|
| 36 |
+
cursor.execute(f"UPDATE links SET status='processing' WHERE id IN ({','.join(ids)})")
|
| 37 |
+
conn.commit()
|
| 38 |
+
except Exception as e:
|
| 39 |
+
links = []
|
| 40 |
+
|
| 41 |
+
conn.close()
|
| 42 |
+
return api_keys, links
|
| 43 |
+
|
| 44 |
+
def trigger_workers(db_path, worker_urls_str, links_per_worker):
|
| 45 |
+
worker_urls = [u.strip() for u in worker_urls_str.split(',') if u.strip()]
|
| 46 |
+
if not worker_urls:
|
| 47 |
+
return "Error: No worker URLs provided."
|
| 48 |
+
|
| 49 |
+
api_keys, links = fetch_data(db_path, limit_links=int(links_per_worker) * len(worker_urls))
|
| 50 |
+
if not api_keys:
|
| 51 |
+
return "Error: No API keys found in DB."
|
| 52 |
+
if not links:
|
| 53 |
+
return "Error: No pending links found in DB."
|
| 54 |
+
|
| 55 |
+
link_chunks = [links[i:i + int(links_per_worker)] for i in range(0, len(links), int(links_per_worker))]
|
| 56 |
+
|
| 57 |
+
logs = []
|
| 58 |
+
logs.append(f"Loaded {len(api_keys)} API keys and {len(links)} links.")
|
| 59 |
+
logs.append(f"Divided into {len(link_chunks)} worker chunks.")
|
| 60 |
+
|
| 61 |
+
for idx, chunk in enumerate(link_chunks):
|
| 62 |
+
if idx >= len(worker_urls):
|
| 63 |
+
logs.append("More chunks than workers. Remaining chunks won't be sent.")
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
worker_url = worker_urls[idx]
|
| 67 |
+
api_key = api_keys[idx % len(api_keys)]
|
| 68 |
+
|
| 69 |
+
payload = {
|
| 70 |
+
"links": chunk,
|
| 71 |
+
"api_key": api_key
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
endpoint = f"{worker_url.rstrip('/')}/process"
|
| 75 |
+
try:
|
| 76 |
+
resp = requests.post(endpoint, json=payload, timeout=10)
|
| 77 |
+
logs.append(f"Sent {len(chunk)} links to {worker_url}: {resp.status_code} - {resp.text}")
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logs.append(f"Failed to send to {worker_url}: {str(e)}")
|
| 80 |
+
|
| 81 |
+
return "\n".join(logs)
|
| 82 |
+
|
| 83 |
+
import json
|
| 84 |
+
import uuid
|
| 85 |
+
import shutil
|
| 86 |
+
import subprocess
|
| 87 |
+
from pathlib import Path
|
| 88 |
+
|
| 89 |
+
def get_kaggle_username():
|
| 90 |
+
kaggle_json = Path.home() / ".kaggle" / "kaggle.json"
|
| 91 |
+
if not kaggle_json.exists():
|
| 92 |
+
return "your-username"
|
| 93 |
+
with open(kaggle_json, "r") as f:
|
| 94 |
+
creds = json.load(f)
|
| 95 |
+
return creds.get("username", "your-username")
|
| 96 |
+
|
| 97 |
+
def trigger_kaggle_test():
|
| 98 |
+
username = get_kaggle_username()
|
| 99 |
+
worker_title = f"yt-test-worker-{uuid.uuid4().hex[:6]}"
|
| 100 |
+
worker_dir = Path("kaggle_test_worker")
|
| 101 |
+
|
| 102 |
+
if worker_dir.exists():
|
| 103 |
+
shutil.rmtree(worker_dir)
|
| 104 |
+
worker_dir.mkdir(parents=True)
|
| 105 |
+
|
| 106 |
+
notebook_content = {
|
| 107 |
+
"cells": [
|
| 108 |
+
{
|
| 109 |
+
"cell_type": "code",
|
| 110 |
+
"execution_count": None,
|
| 111 |
+
"metadata": {},
|
| 112 |
+
"outputs": [],
|
| 113 |
+
"source": [
|
| 114 |
+
"print('Hello from Kaggle Worker!')\n",
|
| 115 |
+
"print('Test successful.')\n"
|
| 116 |
+
]
|
| 117 |
+
}
|
| 118 |
+
],
|
| 119 |
+
"metadata": {
|
| 120 |
+
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
| 121 |
+
"language_info": {"name": "python"}
|
| 122 |
+
},
|
| 123 |
+
"nbformat": 4,
|
| 124 |
+
"nbformat_minor": 4
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
with open(worker_dir / "notebook.ipynb", "w") as f:
|
| 128 |
+
json.dump(notebook_content, f, indent=2)
|
| 129 |
+
|
| 130 |
+
metadata = {
|
| 131 |
+
"id": f"{username}/{worker_title}",
|
| 132 |
+
"title": worker_title,
|
| 133 |
+
"code_file": "notebook.ipynb",
|
| 134 |
+
"language": "python",
|
| 135 |
+
"kernel_type": "notebook",
|
| 136 |
+
"is_private": True,
|
| 137 |
+
"enable_gpu": False,
|
| 138 |
+
"enable_internet": True,
|
| 139 |
+
"dataset_sources": [],
|
| 140 |
+
"competition_sources": [],
|
| 141 |
+
"kernel_sources": []
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
with open(worker_dir / "kernel-metadata.json", "w") as f:
|
| 145 |
+
json.dump(metadata, f, indent=2)
|
| 146 |
+
|
| 147 |
+
logs = [f"Prepared Kaggle Test Worker: {worker_title}"]
|
| 148 |
+
|
| 149 |
+
# Use kaggle from PATH (assuming it is installed via requirements.txt)
|
| 150 |
+
command = ["kaggle", "kernels", "push", "-p", str(worker_dir)]
|
| 151 |
+
logs.append(f"Pushing {worker_title} to Kaggle...")
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
res = subprocess.run(command, capture_output=True, text=True)
|
| 155 |
+
if res.returncode == 0:
|
| 156 |
+
logs.append("Push successful.")
|
| 157 |
+
logs.append(res.stdout)
|
| 158 |
+
else:
|
| 159 |
+
logs.append("Push failed.")
|
| 160 |
+
logs.append(res.stderr)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logs.append(f"Error running kaggle command: {e}")
|
| 163 |
+
logs.append("Make sure Kaggle API credentials are provided in ~/.kaggle/kaggle.json")
|
| 164 |
+
|
| 165 |
+
return "\n".join(logs)
|
| 166 |
+
|
| 167 |
+
def create_worker_notebook(worker_dir, config_name):
|
| 168 |
+
notebook_content = {
|
| 169 |
+
"cells": [
|
| 170 |
+
{
|
| 171 |
+
"cell_type": "code",
|
| 172 |
+
"execution_count": None,
|
| 173 |
+
"metadata": {},
|
| 174 |
+
"outputs": [],
|
| 175 |
+
"source": [
|
| 176 |
+
"!pip install openai huggingface_hub yt-dlp opencv-python-headless\n"
|
| 177 |
+
]
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"cell_type": "code",
|
| 181 |
+
"execution_count": None,
|
| 182 |
+
"metadata": {},
|
| 183 |
+
"outputs": [],
|
| 184 |
+
"source": [
|
| 185 |
+
"import json\n",
|
| 186 |
+
"import os\n",
|
| 187 |
+
"import subprocess\n",
|
| 188 |
+
"import sys\n",
|
| 189 |
+
"\n",
|
| 190 |
+
f"with open('{config_name}') as f:\n",
|
| 191 |
+
" config = json.load(f)\n",
|
| 192 |
+
"\n",
|
| 193 |
+
"links = config['links']\n",
|
| 194 |
+
"api_key = config['api_key']\n",
|
| 195 |
+
"os.environ['FEATHERLESS_API_KEY'] = api_key\n",
|
| 196 |
+
"\n",
|
| 197 |
+
"print(f'Starting worker with {len(links)} links using API Key: {api_key[:5]}...')\n",
|
| 198 |
+
"\n",
|
| 199 |
+
"for link in links:\n",
|
| 200 |
+
" print(f'\\n[+] Processing: {link}')\n",
|
| 201 |
+
" # 1. Download Video\n",
|
| 202 |
+
" dl_cmd = [sys.executable, 'worker/youtube_worker/downloader.py', link, '--resolution', 'HD', '-o', 'test_output']\n",
|
| 203 |
+
" res = subprocess.run(dl_cmd, capture_output=True, text=True)\n",
|
| 204 |
+
" if res.returncode != 0:\n",
|
| 205 |
+
" print(f'Download failed:\\n{res.stderr}')\n",
|
| 206 |
+
" continue\n",
|
| 207 |
+
" \n",
|
| 208 |
+
" # Find the downloaded video path from stdout (last line)\n",
|
| 209 |
+
" lines = res.stdout.strip().split('\\n')\n",
|
| 210 |
+
" video_path = lines[-1]\n",
|
| 211 |
+
" if not os.path.exists(video_path):\n",
|
| 212 |
+
" print(f'Could not find downloaded video at: {video_path}')\n",
|
| 213 |
+
" continue\n",
|
| 214 |
+
" \n",
|
| 215 |
+
" print(f'[+] Video downloaded to: {video_path}')\n",
|
| 216 |
+
" \n",
|
| 217 |
+
" # 2. Curate Model (Video Worker)\n",
|
| 218 |
+
" prompt = 'Analyze this video comprehensively. Focus on visuals, objects, and text.'\n",
|
| 219 |
+
" vw_cmd = [sys.executable, 'worker/models_worker/video_worker.py', '--video-url', video_path, '--prompt', prompt, '--api-key', api_key]\n",
|
| 220 |
+
" print('[+] Running model curation...')\n",
|
| 221 |
+
" subprocess.run(vw_cmd)\n",
|
| 222 |
+
" \n",
|
| 223 |
+
" # 3. Optional: Upload to HF\n",
|
| 224 |
+
" # hf_cmd = [sys.executable, 'worker/hf_uploader.py', 'test_output']\n",
|
| 225 |
+
" # subprocess.run(hf_cmd)\n",
|
| 226 |
+
"\n",
|
| 227 |
+
"print('\\nAll tasks completed!')\n"
|
| 228 |
+
]
|
| 229 |
+
}
|
| 230 |
+
],
|
| 231 |
+
"metadata": {
|
| 232 |
+
"kernelspec": {
|
| 233 |
+
"display_name": "Python 3",
|
| 234 |
+
"language": "python",
|
| 235 |
+
"name": "python3"
|
| 236 |
+
},
|
| 237 |
+
"language_info": {
|
| 238 |
+
"name": "python"
|
| 239 |
+
}
|
| 240 |
+
},
|
| 241 |
+
"nbformat": 4,
|
| 242 |
+
"nbformat_minor": 4
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
with open(worker_dir / "notebook.ipynb", "w") as f:
|
| 246 |
+
json.dump(notebook_content, f, indent=2)
|
| 247 |
+
|
| 248 |
+
def trigger_kaggle_workers(db_path, links_per_worker, max_workers):
|
| 249 |
+
api_keys, links = fetch_data(db_path, limit_links=int(links_per_worker) * int(max_workers))
|
| 250 |
+
if not api_keys:
|
| 251 |
+
return "Error: No API keys found in DB."
|
| 252 |
+
if not links:
|
| 253 |
+
return "Error: No pending links found in DB."
|
| 254 |
+
|
| 255 |
+
username = get_kaggle_username()
|
| 256 |
+
workers_dir = Path("kaggle_workers_out")
|
| 257 |
+
if workers_dir.exists():
|
| 258 |
+
shutil.rmtree(workers_dir)
|
| 259 |
+
workers_dir.mkdir(parents=True)
|
| 260 |
+
|
| 261 |
+
logs = []
|
| 262 |
+
logs.append(f"Loaded {len(api_keys)} API keys and {len(links)} links.")
|
| 263 |
+
|
| 264 |
+
link_chunks = [links[i:i + int(links_per_worker)] for i in range(0, len(links), int(links_per_worker))]
|
| 265 |
+
logs.append(f"Divided into {len(link_chunks)} worker chunks.")
|
| 266 |
+
|
| 267 |
+
for idx, chunk in enumerate(link_chunks):
|
| 268 |
+
worker_id = f"worker-{uuid.uuid4().hex[:8]}"
|
| 269 |
+
worker_title = f"yt-worker-{idx+1}-{uuid.uuid4().hex[:4]}"
|
| 270 |
+
|
| 271 |
+
api_key = api_keys[idx % len(api_keys)]
|
| 272 |
+
worker_path = workers_dir / worker_id
|
| 273 |
+
worker_path.mkdir(parents=True)
|
| 274 |
+
|
| 275 |
+
# Copy worker if exists locally
|
| 276 |
+
if Path("worker").exists():
|
| 277 |
+
shutil.copytree("worker", worker_path / "worker")
|
| 278 |
+
else:
|
| 279 |
+
# We copy from parent directory if this is running locally, otherwise it's an error on HF unless packed
|
| 280 |
+
parent_worker = Path("../hf_space_worker/worker")
|
| 281 |
+
if parent_worker.exists():
|
| 282 |
+
shutil.copytree(parent_worker, worker_path / "worker")
|
| 283 |
+
else:
|
| 284 |
+
logs.append("Warning: 'worker' directory not found. Kaggle notebook may fail.")
|
| 285 |
+
|
| 286 |
+
if Path("cookies.txt").exists():
|
| 287 |
+
shutil.copy("cookies.txt", worker_path / "cookies.txt")
|
| 288 |
+
elif Path("../cookies.txt").exists():
|
| 289 |
+
shutil.copy("../cookies.txt", worker_path / "cookies.txt")
|
| 290 |
+
|
| 291 |
+
if Path("cookies.json").exists():
|
| 292 |
+
shutil.copy("cookies.json", worker_path / "cookies.json")
|
| 293 |
+
elif Path("../cookies.json").exists():
|
| 294 |
+
shutil.copy("../cookies.json", worker_path / "cookies.json")
|
| 295 |
+
|
| 296 |
+
config_name = "worker_config.json"
|
| 297 |
+
config_data = {
|
| 298 |
+
"links": chunk,
|
| 299 |
+
"api_key": api_key,
|
| 300 |
+
}
|
| 301 |
+
with open(worker_path / config_name, "w") as f:
|
| 302 |
+
json.dump(config_data, f, indent=2)
|
| 303 |
+
|
| 304 |
+
create_worker_notebook(worker_path, config_name)
|
| 305 |
+
|
| 306 |
+
metadata = {
|
| 307 |
+
"id": f"{username}/{worker_title}",
|
| 308 |
+
"title": worker_title,
|
| 309 |
+
"code_file": "notebook.ipynb",
|
| 310 |
+
"language": "python",
|
| 311 |
+
"kernel_type": "notebook",
|
| 312 |
+
"is_private": True,
|
| 313 |
+
"enable_gpu": False,
|
| 314 |
+
"enable_internet": True,
|
| 315 |
+
"dataset_sources": [],
|
| 316 |
+
"competition_sources": [],
|
| 317 |
+
"kernel_sources": []
|
| 318 |
+
}
|
| 319 |
+
with open(worker_path / "kernel-metadata.json", "w") as f:
|
| 320 |
+
json.dump(metadata, f, indent=2)
|
| 321 |
+
|
| 322 |
+
logs.append(f"Prepared Worker {idx+1}: {worker_title} with {len(chunk)} links.")
|
| 323 |
+
|
| 324 |
+
command = ["kaggle", "kernels", "push", "-p", str(worker_path)]
|
| 325 |
+
logs.append(f"Pushing {worker_title} to Kaggle...")
|
| 326 |
+
try:
|
| 327 |
+
res = subprocess.run(command, capture_output=True, text=True)
|
| 328 |
+
if res.returncode == 0:
|
| 329 |
+
logs.append("Push successful.")
|
| 330 |
+
else:
|
| 331 |
+
logs.append("Push failed.")
|
| 332 |
+
logs.append(res.stderr)
|
| 333 |
+
except Exception as e:
|
| 334 |
+
logs.append(f"Error: {e}")
|
| 335 |
+
|
| 336 |
+
return "\n".join(logs)
|
| 337 |
+
|
| 338 |
+
def sync_db(db_path, api_keys_text, links_text):
|
| 339 |
+
if not db_path:
|
| 340 |
+
return "Error: DB Path is empty."
|
| 341 |
+
|
| 342 |
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
| 343 |
+
conn = sqlite3.connect(db_path)
|
| 344 |
+
cursor = conn.cursor()
|
| 345 |
+
|
| 346 |
+
# Ensure tables exist
|
| 347 |
+
cursor.execute('''CREATE TABLE IF NOT EXISTS links (
|
| 348 |
+
id INTEGER PRIMARY KEY,
|
| 349 |
+
url TEXT,
|
| 350 |
+
status TEXT DEFAULT 'pending'
|
| 351 |
+
)''')
|
| 352 |
+
|
| 353 |
+
cursor.execute('''CREATE TABLE IF NOT EXISTS accounts (
|
| 354 |
+
id INTEGER PRIMARY KEY,
|
| 355 |
+
email TEXT,
|
| 356 |
+
api_key TEXT,
|
| 357 |
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 358 |
+
)''')
|
| 359 |
+
|
| 360 |
+
logs = []
|
| 361 |
+
|
| 362 |
+
# Add API keys
|
| 363 |
+
api_keys = [k.strip() for k in api_keys_text.split('\n') if k.strip()]
|
| 364 |
+
if api_keys:
|
| 365 |
+
added_keys = 0
|
| 366 |
+
for key in api_keys:
|
| 367 |
+
try:
|
| 368 |
+
# Basic check to avoid exact duplicates
|
| 369 |
+
cursor.execute("SELECT id FROM accounts WHERE api_key=?", (key,))
|
| 370 |
+
if not cursor.fetchone():
|
| 371 |
+
cursor.execute("INSERT INTO accounts (email, api_key) VALUES (?, ?)", (f"user_{uuid.uuid4().hex[:4]}@example.com", key))
|
| 372 |
+
added_keys += 1
|
| 373 |
+
except Exception as e:
|
| 374 |
+
logs.append(f"Error adding key {key}: {e}")
|
| 375 |
+
conn.commit()
|
| 376 |
+
logs.append(f"Successfully added {added_keys} new API keys.")
|
| 377 |
+
|
| 378 |
+
# Add Links
|
| 379 |
+
links = [l.strip() for l in links_text.split('\n') if l.strip()]
|
| 380 |
+
if links:
|
| 381 |
+
added_links = 0
|
| 382 |
+
for link in links:
|
| 383 |
+
try:
|
| 384 |
+
# Basic check to avoid exact duplicates
|
| 385 |
+
cursor.execute("SELECT id FROM links WHERE url=?", (link,))
|
| 386 |
+
if not cursor.fetchone():
|
| 387 |
+
cursor.execute("INSERT INTO links (url, status) VALUES (?, 'pending')", (link,))
|
| 388 |
+
added_links += 1
|
| 389 |
+
except Exception as e:
|
| 390 |
+
logs.append(f"Error adding link {link}: {e}")
|
| 391 |
+
conn.commit()
|
| 392 |
+
logs.append(f"Successfully added {added_links} new pending links.")
|
| 393 |
+
|
| 394 |
+
conn.close()
|
| 395 |
+
|
| 396 |
+
if not api_keys and not links:
|
| 397 |
+
return "No API keys or links provided to sync."
|
| 398 |
+
|
| 399 |
+
return "\n".join(logs)
|
| 400 |
+
|
| 401 |
+
def run_v3_launch(max_workers, videos_per_worker):
|
| 402 |
+
cmd = ["python", "pipeline/orchestrator.py", "launch",
|
| 403 |
+
"--max-workers", str(int(max_workers)),
|
| 404 |
+
"--videos-per-worker", str(int(videos_per_worker)),
|
| 405 |
+
"--push"]
|
| 406 |
+
try:
|
| 407 |
+
# Run in a subprocess and capture output
|
| 408 |
+
res = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.dirname(os.path.abspath(__file__)))
|
| 409 |
+
return f"--- LAUNCH OUTPUT ---\n{res.stdout}\n{res.stderr}"
|
| 410 |
+
except Exception as e:
|
| 411 |
+
return f"Error: {e}"
|
| 412 |
+
|
| 413 |
+
def run_v3_sync():
|
| 414 |
+
cmd = ["python", "pipeline/orchestrator.py", "sync"]
|
| 415 |
+
try:
|
| 416 |
+
res = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.dirname(os.path.abspath(__file__)))
|
| 417 |
+
return f"--- SYNC OUTPUT ---\n{res.stdout}\n{res.stderr}"
|
| 418 |
+
except Exception as e:
|
| 419 |
+
return f"Error: {e}"
|
| 420 |
+
|
| 421 |
+
def run_v3_status():
|
| 422 |
+
cmd = ["python", "pipeline/orchestrator.py", "status"]
|
| 423 |
+
try:
|
| 424 |
+
res = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.dirname(os.path.abspath(__file__)))
|
| 425 |
+
return f"--- STATUS OUTPUT ---\n{res.stdout}\n{res.stderr}"
|
| 426 |
+
except Exception as e:
|
| 427 |
+
return f"Error: {e}"
|
| 428 |
+
|
| 429 |
+
with gr.Blocks() as app:
|
| 430 |
+
gr.Markdown("# YouTube Video Orchestrator Host")
|
| 431 |
+
gr.Markdown("Deploy this to Hugging Face Spaces or run locally. It fetches pending links from your database and delegates them to FastAPI workers or Kaggle.")
|
| 432 |
+
|
| 433 |
+
with gr.Tabs():
|
| 434 |
+
with gr.Tab("V3 Pipeline Orchestrator"):
|
| 435 |
+
gr.Markdown("Directly run the advanced `pipeline/orchestrator.py` commands from this dashboard!")
|
| 436 |
+
|
| 437 |
+
with gr.Row():
|
| 438 |
+
v3_max_workers = gr.Number(label="Max Workers", value=5)
|
| 439 |
+
v3_videos = gr.Number(label="Videos per Worker", value=250)
|
| 440 |
+
|
| 441 |
+
with gr.Row():
|
| 442 |
+
v3_launch_btn = gr.Button("Launch Workers (--push)", variant="primary")
|
| 443 |
+
v3_sync_btn = gr.Button("Sync DB & Status")
|
| 444 |
+
v3_status_btn = gr.Button("Check Status")
|
| 445 |
+
|
| 446 |
+
v3_output_log = gr.Textbox(label="Terminal Output", lines=15)
|
| 447 |
+
|
| 448 |
+
v3_launch_btn.click(fn=run_v3_launch, inputs=[v3_max_workers, v3_videos], outputs=v3_output_log)
|
| 449 |
+
v3_sync_btn.click(fn=run_v3_sync, inputs=[], outputs=v3_output_log)
|
| 450 |
+
v3_status_btn.click(fn=run_v3_status, inputs=[], outputs=v3_output_log)
|
| 451 |
+
|
| 452 |
+
with gr.Tab("Database Sync"):
|
| 453 |
+
gr.Markdown("Sync (Add) new API keys and YouTube Links into your database.")
|
| 454 |
+
with gr.Row():
|
| 455 |
+
sync_db_input = gr.Textbox(label="DB Path", value="/data/db/accounts.db")
|
| 456 |
+
with gr.Row():
|
| 457 |
+
api_keys_input = gr.Textbox(label="API Keys (One per line)", lines=5)
|
| 458 |
+
links_input = gr.Textbox(label="YouTube Links (One per line)", lines=5)
|
| 459 |
+
|
| 460 |
+
sync_btn = gr.Button("Sync to Database", variant="primary")
|
| 461 |
+
sync_log = gr.Textbox(label="Sync Logs", lines=5)
|
| 462 |
+
|
| 463 |
+
sync_btn.click(fn=sync_db, inputs=[sync_db_input, api_keys_input, links_input], outputs=sync_log)
|
| 464 |
+
|
| 465 |
+
with gr.Tab("FastAPI Workers"):
|
| 466 |
+
with gr.Row():
|
| 467 |
+
db_input = gr.Textbox(label="DB Path", value="/data/db/accounts.db")
|
| 468 |
+
workers_input = gr.Textbox(label="Worker URLs (comma separated)", placeholder="http://localhost:7860, https://your-worker.hf.space")
|
| 469 |
+
links_per_worker = gr.Number(label="Links per worker", value=2)
|
| 470 |
+
|
| 471 |
+
run_btn = gr.Button("Run Orchestration", variant="primary")
|
| 472 |
+
output_log = gr.Textbox(label="Logs", lines=10)
|
| 473 |
+
|
| 474 |
+
run_btn.click(fn=trigger_workers, inputs=[db_input, workers_input, links_per_worker], outputs=output_log)
|
| 475 |
+
|
| 476 |
+
with gr.Tab("Simple Kaggle Workers"):
|
| 477 |
+
gr.Markdown("Trigger simple Kaggle notebook workers. Note: You must mount `kaggle.json` to `~/.kaggle/kaggle.json` in the container for this to work.")
|
| 478 |
+
|
| 479 |
+
with gr.Row():
|
| 480 |
+
k_db_input = gr.Textbox(label="DB Path", value="/data/db/accounts.db")
|
| 481 |
+
k_links_per_worker = gr.Number(label="Links per worker", value=2)
|
| 482 |
+
k_max_workers = gr.Number(label="Max Workers", value=5)
|
| 483 |
+
|
| 484 |
+
k_run_btn = gr.Button("Run Simple Kaggle Orchestration", variant="primary")
|
| 485 |
+
k_test_run_btn = gr.Button("Run Simple Kaggle Test Worker", variant="secondary")
|
| 486 |
+
kaggle_log = gr.Textbox(label="Kaggle Logs", lines=15)
|
| 487 |
+
|
| 488 |
+
k_run_btn.click(fn=trigger_kaggle_workers, inputs=[k_db_input, k_links_per_worker, k_max_workers], outputs=kaggle_log)
|
| 489 |
+
k_test_run_btn.click(fn=trigger_kaggle_test, inputs=[], outputs=kaggle_log)
|
| 490 |
+
|
| 491 |
+
if __name__ == "__main__":
|
| 492 |
+
app.launch(server_name="0.0.0.0", server_port=7860)
|
pipeline/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# YT Video Analysis Pipeline
|
pipeline/orchestrator.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
YT Pipeline Orchestrator v3 — Host System (Part 2)
|
| 4 |
+
===================================================
|
| 5 |
+
Kaggle limits: max 10 concurrent sessions, 12hr max runtime.
|
| 6 |
+
Auto-rotates workers, syncs results, retries failed tasks.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python orchestrator.py run --videos-per-worker 3 # auto-pilot loop
|
| 10 |
+
python orchestrator.py launch --max-workers 5 # one-shot launch
|
| 11 |
+
python orchestrator.py status
|
| 12 |
+
python orchestrator.py sync
|
| 13 |
+
python orchestrator.py retry
|
| 14 |
+
python orchestrator.py import
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse, json, logging, os, shutil, sqlite3, subprocess, sys
|
| 18 |
+
import time, uuid
|
| 19 |
+
from datetime import datetime, timezone, timedelta
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
logging.basicConfig(
|
| 23 |
+
level=logging.INFO,
|
| 24 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 25 |
+
datefmt="%H:%M:%S",
|
| 26 |
+
)
|
| 27 |
+
log = logging.getLogger("orchestrator")
|
| 28 |
+
|
| 29 |
+
# ── paths & constants ────────────────────────────────────────────────
|
| 30 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 31 |
+
DB_ACCOUNTS = BASE_DIR / "db" / "accounts.db"
|
| 32 |
+
DB_CHANNELS = BASE_DIR / "db" / "channel_links.db"
|
| 33 |
+
DB_TRACKER = BASE_DIR / "db" / "tracker.db"
|
| 34 |
+
WORKERS_DIR = BASE_DIR / "kaggle_workers"
|
| 35 |
+
WORKER_SCRIPT = Path(__file__).resolve().parent / "worker.py"
|
| 36 |
+
HF_REPO = "AdhyanshVerma/YT"
|
| 37 |
+
|
| 38 |
+
# kaggle limits
|
| 39 |
+
MAX_KAGGLE_SESSIONS = 5
|
| 40 |
+
KAGGLE_TIMEOUT_HOURS = 12
|
| 41 |
+
POLL_INTERVAL_SEC = 120 # check every 2 min in run loop
|
| 42 |
+
SAFETY_MARGIN_MIN = 30 # requeue if within 30min of 12hr limit
|
| 43 |
+
|
| 44 |
+
# video states
|
| 45 |
+
S_PENDING = "pending"
|
| 46 |
+
S_ASSIGNED = "assigned"
|
| 47 |
+
S_DONE = "done"
|
| 48 |
+
S_FAILED = "failed"
|
| 49 |
+
S_TIMEOUT = "timeout"
|
| 50 |
+
|
| 51 |
+
# worker states
|
| 52 |
+
WS_CREATED = "created"
|
| 53 |
+
WS_PUSHED = "pushed"
|
| 54 |
+
WS_RUNNING = "running"
|
| 55 |
+
WS_COMPLETE = "complete"
|
| 56 |
+
WS_ERROR = "error"
|
| 57 |
+
WS_TIMEOUT = "timeout"
|
| 58 |
+
WS_CANCELLED = "cancelled"
|
| 59 |
+
|
| 60 |
+
# ── tracker database ─────────────────────────────────────────────────
|
| 61 |
+
class Tracker:
|
| 62 |
+
def __init__(self, db_path=DB_TRACKER):
|
| 63 |
+
self.db_path = str(db_path)
|
| 64 |
+
self._init_db()
|
| 65 |
+
|
| 66 |
+
def _conn(self):
|
| 67 |
+
c = sqlite3.connect(self.db_path)
|
| 68 |
+
c.row_factory = sqlite3.Row
|
| 69 |
+
c.execute("PRAGMA journal_mode=WAL")
|
| 70 |
+
return c
|
| 71 |
+
|
| 72 |
+
def _init_db(self):
|
| 73 |
+
with self._conn() as c:
|
| 74 |
+
c.execute("""CREATE TABLE IF NOT EXISTS videos (
|
| 75 |
+
video_id TEXT PRIMARY KEY, url TEXT NOT NULL,
|
| 76 |
+
title TEXT DEFAULT '', channel TEXT DEFAULT '',
|
| 77 |
+
duration REAL DEFAULT 0, status TEXT DEFAULT 'pending',
|
| 78 |
+
worker_id TEXT DEFAULT '', assigned_at TEXT DEFAULT '',
|
| 79 |
+
completed_at TEXT DEFAULT '', error_msg TEXT DEFAULT '',
|
| 80 |
+
retry_count INTEGER DEFAULT 0
|
| 81 |
+
)""")
|
| 82 |
+
c.execute("""CREATE TABLE IF NOT EXISTS workers (
|
| 83 |
+
worker_id TEXT PRIMARY KEY, kaggle_title TEXT DEFAULT '',
|
| 84 |
+
video_count INTEGER DEFAULT 0, status TEXT DEFAULT 'created',
|
| 85 |
+
created_at TEXT DEFAULT '', pushed_at TEXT DEFAULT '',
|
| 86 |
+
finished_at TEXT DEFAULT '', kaggle_status TEXT DEFAULT ''
|
| 87 |
+
)""")
|
| 88 |
+
c.execute("CREATE INDEX IF NOT EXISTS idx_vid_status ON videos(status)")
|
| 89 |
+
c.execute("CREATE INDEX IF NOT EXISTS idx_w_status ON workers(status)")
|
| 90 |
+
# migrate old tables missing kaggle_status
|
| 91 |
+
try:
|
| 92 |
+
c.execute("ALTER TABLE workers ADD COLUMN kaggle_status TEXT DEFAULT ''")
|
| 93 |
+
except Exception:
|
| 94 |
+
pass # column already exists
|
| 95 |
+
|
| 96 |
+
def import_from_channels_db(self):
|
| 97 |
+
if not DB_CHANNELS.exists():
|
| 98 |
+
return 0
|
| 99 |
+
src = sqlite3.connect(str(DB_CHANNELS))
|
| 100 |
+
rows = src.execute("SELECT video_id,url,title,channel_url,duration FROM videos").fetchall()
|
| 101 |
+
src.close()
|
| 102 |
+
count = 0
|
| 103 |
+
with self._conn() as c:
|
| 104 |
+
for r in rows:
|
| 105 |
+
try:
|
| 106 |
+
c.execute("INSERT OR IGNORE INTO videos (video_id,url,title,channel,duration,status) VALUES (?,?,?,?,?,?)",
|
| 107 |
+
(r[0], r[1], r[2] or "", r[3] or "", r[4] or 0, S_PENDING))
|
| 108 |
+
count += 1
|
| 109 |
+
except: pass
|
| 110 |
+
log.info(f"Imported {count} videos")
|
| 111 |
+
return count
|
| 112 |
+
|
| 113 |
+
def get_pending(self, limit):
|
| 114 |
+
with self._conn() as c:
|
| 115 |
+
return c.execute("SELECT video_id,url,title FROM videos WHERE status=? ORDER BY duration ASC LIMIT ?",
|
| 116 |
+
(S_PENDING, limit)).fetchall()
|
| 117 |
+
|
| 118 |
+
def assign_batch(self, video_ids, worker_id):
|
| 119 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 120 |
+
with self._conn() as c:
|
| 121 |
+
for vid in video_ids:
|
| 122 |
+
c.execute("UPDATE videos SET status=?,worker_id=?,assigned_at=? WHERE video_id=?",
|
| 123 |
+
(S_ASSIGNED, worker_id, now, vid))
|
| 124 |
+
|
| 125 |
+
def requeue_worker_videos(self, worker_id, new_status=S_PENDING):
|
| 126 |
+
"""Put assigned videos from a dead/timed-out worker back to pending."""
|
| 127 |
+
with self._conn() as c:
|
| 128 |
+
r = c.execute("UPDATE videos SET status=?,worker_id='' WHERE worker_id=? AND status=?",
|
| 129 |
+
(new_status, worker_id, S_ASSIGNED))
|
| 130 |
+
return r.rowcount
|
| 131 |
+
|
| 132 |
+
def mark_worker(self, worker_id, status, kaggle_status=""):
|
| 133 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 134 |
+
with self._conn() as c:
|
| 135 |
+
if status in (WS_COMPLETE, WS_ERROR, WS_TIMEOUT, WS_CANCELLED):
|
| 136 |
+
c.execute("UPDATE workers SET status=?,finished_at=?,kaggle_status=? WHERE worker_id=?",
|
| 137 |
+
(status, now, kaggle_status, worker_id))
|
| 138 |
+
else:
|
| 139 |
+
c.execute("UPDATE workers SET status=?,kaggle_status=? WHERE worker_id=?",
|
| 140 |
+
(status, kaggle_status, worker_id))
|
| 141 |
+
|
| 142 |
+
def update_video_statuses(self, status_dict):
|
| 143 |
+
with self._conn() as c:
|
| 144 |
+
for vid, st in status_dict.items():
|
| 145 |
+
if st in (S_DONE, S_FAILED):
|
| 146 |
+
c.execute("UPDATE videos SET status=? WHERE video_id=? AND status!=?",
|
| 147 |
+
(st, vid, st))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def register_worker(self, worker_id, kaggle_title, video_count):
|
| 151 |
+
with self._conn() as c:
|
| 152 |
+
c.execute("INSERT OR REPLACE INTO workers VALUES (?,?,?,?,?,?,?,?)",
|
| 153 |
+
(worker_id, kaggle_title, video_count, WS_CREATED,
|
| 154 |
+
datetime.now(timezone.utc).isoformat(), "", "", ""))
|
| 155 |
+
|
| 156 |
+
def mark_worker_pushed(self, worker_id):
|
| 157 |
+
with self._conn() as c:
|
| 158 |
+
c.execute("UPDATE workers SET status=?,pushed_at=? WHERE worker_id=?",
|
| 159 |
+
(WS_PUSHED, datetime.now(timezone.utc).isoformat(), worker_id))
|
| 160 |
+
|
| 161 |
+
def active_workers(self):
|
| 162 |
+
"""Workers that are pushed/running (consuming Kaggle slots)."""
|
| 163 |
+
with self._conn() as c:
|
| 164 |
+
return c.execute("SELECT * FROM workers WHERE status IN (?,?)",
|
| 165 |
+
(WS_PUSHED, WS_RUNNING)).fetchall()
|
| 166 |
+
|
| 167 |
+
def pushed_workers(self):
|
| 168 |
+
with self._conn() as c:
|
| 169 |
+
return c.execute("SELECT * FROM workers WHERE status IN (?,?)",
|
| 170 |
+
(WS_PUSHED, WS_RUNNING)).fetchall()
|
| 171 |
+
|
| 172 |
+
def timed_out_workers(self):
|
| 173 |
+
"""Workers pushed more than 12hrs ago still active."""
|
| 174 |
+
cutoff = (datetime.now(timezone.utc) - timedelta(hours=KAGGLE_TIMEOUT_HOURS,
|
| 175 |
+
minutes=-SAFETY_MARGIN_MIN)).isoformat()
|
| 176 |
+
with self._conn() as c:
|
| 177 |
+
return c.execute("SELECT * FROM workers WHERE status IN (?,?) AND pushed_at<? AND pushed_at!=''",
|
| 178 |
+
(WS_PUSHED, WS_RUNNING, cutoff)).fetchall()
|
| 179 |
+
|
| 180 |
+
def retry_failed(self, max_retries=3):
|
| 181 |
+
with self._conn() as c:
|
| 182 |
+
r = c.execute("UPDATE videos SET status=? WHERE status IN (?,?) AND retry_count<?",
|
| 183 |
+
(S_PENDING, S_FAILED, S_TIMEOUT, max_retries))
|
| 184 |
+
return r.rowcount
|
| 185 |
+
|
| 186 |
+
def stats(self):
|
| 187 |
+
with self._conn() as c:
|
| 188 |
+
rows = c.execute("SELECT status,COUNT(*) FROM videos GROUP BY status").fetchall()
|
| 189 |
+
return {r[0]: r[1] for r in rows}
|
| 190 |
+
|
| 191 |
+
def worker_stats(self):
|
| 192 |
+
with self._conn() as c:
|
| 193 |
+
return c.execute("SELECT * FROM workers ORDER BY created_at DESC LIMIT 20").fetchall()
|
| 194 |
+
|
| 195 |
+
def available_slots(self):
|
| 196 |
+
active = len(self.active_workers())
|
| 197 |
+
return max(0, MAX_KAGGLE_SESSIONS - active)
|
| 198 |
+
|
| 199 |
+
# ── helpers ──────────────────────────────────────────────────────────
|
| 200 |
+
def load_api_keys():
|
| 201 |
+
conn = sqlite3.connect(str(DB_ACCOUNTS))
|
| 202 |
+
rows = conn.execute("SELECT api_key FROM accounts WHERE api_key IS NOT NULL AND api_key!=''").fetchall()
|
| 203 |
+
conn.close()
|
| 204 |
+
return [r[0] for r in rows]
|
| 205 |
+
|
| 206 |
+
def load_hf_token():
|
| 207 |
+
token = os.environ.get("HF_TOKEN", "")
|
| 208 |
+
if not token:
|
| 209 |
+
p = Path.home() / ".cache" / "huggingface" / "token"
|
| 210 |
+
if p.exists(): token = p.read_text().strip()
|
| 211 |
+
return token
|
| 212 |
+
|
| 213 |
+
def get_kaggle_username():
|
| 214 |
+
kf = Path.home() / ".kaggle" / "kaggle.json"
|
| 215 |
+
if kf.exists(): return json.loads(kf.read_text()).get("username", "adhyanshverma")
|
| 216 |
+
return "adhyanshverma"
|
| 217 |
+
|
| 218 |
+
def check_kaggle_kernel_status(kaggle_title, username):
|
| 219 |
+
"""Query Kaggle API for kernel status. Returns: queued/running/complete/error/cancelled or None."""
|
| 220 |
+
try:
|
| 221 |
+
r = subprocess.run(["kaggle","kernels","status",f"{username}/{kaggle_title}"],
|
| 222 |
+
capture_output=True, text=True, timeout=30)
|
| 223 |
+
out = r.stdout.strip().lower()
|
| 224 |
+
for s in ["complete", "error", "cancelled", "running", "queued"]:
|
| 225 |
+
if s in out: return s
|
| 226 |
+
return out if out else None
|
| 227 |
+
except Exception:
|
| 228 |
+
return None
|
| 229 |
+
|
| 230 |
+
# ── notebook generation ──────────────────────────────────────────────
|
| 231 |
+
def generate_notebook(worker_dir, config, worker_code):
|
| 232 |
+
config_json = json.dumps(config, indent=2)
|
| 233 |
+
|
| 234 |
+
cookies_txt = Path("cookies.txt").read_text() if Path("cookies.txt").exists() else ""
|
| 235 |
+
cookies_json_str = Path("cookies.json").read_text() if Path("cookies.json").exists() else ""
|
| 236 |
+
|
| 237 |
+
cells = [
|
| 238 |
+
{"cell_type":"code","execution_count":None,"metadata":{},"outputs":[],
|
| 239 |
+
"source":["!pip install -q yt-dlp huggingface_hub openai faster-whisper pyarrow\n",
|
| 240 |
+
"!apt-get install -y -qq ffmpeg > /dev/null 2>&1\n",
|
| 241 |
+
"print('Dependencies installed ✅')\n"]},
|
| 242 |
+
{"cell_type":"code","execution_count":None,"metadata":{},"outputs":[],
|
| 243 |
+
"source":["from pathlib import Path\n",
|
| 244 |
+
f"Path('cookies.txt').write_text({repr(cookies_txt)})\n",
|
| 245 |
+
f"Path('cookies.json').write_text({repr(cookies_json_str)})\n",
|
| 246 |
+
"print('Cookies written ✅')\n"]},
|
| 247 |
+
{"cell_type":"code","execution_count":None,"metadata":{},"outputs":[],
|
| 248 |
+
"source": worker_code.split("\n")},
|
| 249 |
+
{"cell_type":"code","execution_count":None,"metadata":{},"outputs":[],
|
| 250 |
+
"source":["import json, os\n",
|
| 251 |
+
f"config = json.loads('''{config_json}''')\n",
|
| 252 |
+
"os.environ['HF_TOKEN'] = config['hf_token']\n",
|
| 253 |
+
"status = run_worker(config)\n",
|
| 254 |
+
"print(f'Worker finished: {json.dumps(status, indent=2)}')\n"]},
|
| 255 |
+
]
|
| 256 |
+
for cell in cells:
|
| 257 |
+
cell["source"] = [l if l.endswith("\n") else l+"\n" for l in cell["source"]]
|
| 258 |
+
nb = {"cells":cells,"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},
|
| 259 |
+
"language_info":{"name":"python"}},"nbformat":4,"nbformat_minor":4}
|
| 260 |
+
(worker_dir/"notebook.ipynb").write_text(json.dumps(nb, indent=2))
|
| 261 |
+
|
| 262 |
+
def generate_kernel_metadata(worker_dir, kaggle_title, username):
|
| 263 |
+
meta = {"id":f"{username}/{kaggle_title}","title":kaggle_title,"code_file":"notebook.ipynb",
|
| 264 |
+
"language":"python","kernel_type":"notebook","is_private":True,
|
| 265 |
+
"enable_gpu":False,"enable_internet":True,
|
| 266 |
+
"dataset_sources":[],"competition_sources":[],"kernel_sources":[]}
|
| 267 |
+
(worker_dir/"kernel-metadata.json").write_text(json.dumps(meta, indent=2))
|
| 268 |
+
|
| 269 |
+
def load_vision_models():
|
| 270 |
+
VISION_MODELS_FILE = BASE_DIR / "models" / "vision_models.txt"
|
| 271 |
+
if VISION_MODELS_FILE.exists():
|
| 272 |
+
with open(VISION_MODELS_FILE) as f:
|
| 273 |
+
models = [l.strip() for l in f if l.strip() and not l.strip().startswith("#")]
|
| 274 |
+
if models:
|
| 275 |
+
return models
|
| 276 |
+
return [
|
| 277 |
+
"MiniMaxAI/MiniMax-M3",
|
| 278 |
+
"XiaomiMiMo/MiMo-V2.5",
|
| 279 |
+
"Qwen/Qwen3-VL-32B-Instruct",
|
| 280 |
+
"Qwen/Qwen3.6-27B",
|
| 281 |
+
"Qwen/Qwen3-VL-235B-A22B-Thinking"
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
# ── launch N workers (respecting slot limit) ─────────────────────────
|
| 285 |
+
def launch_workers(tracker, num_workers, videos_per_worker, push=False):
|
| 286 |
+
slots = tracker.available_slots()
|
| 287 |
+
if slots <= 0:
|
| 288 |
+
log.info(f"No Kaggle slots available (all {MAX_KAGGLE_SESSIONS} in use)")
|
| 289 |
+
return 0
|
| 290 |
+
num_workers = min(num_workers, slots)
|
| 291 |
+
total_fetch = num_workers * videos_per_worker
|
| 292 |
+
pending = tracker.get_pending(total_fetch)
|
| 293 |
+
if not pending:
|
| 294 |
+
log.info("No pending videos")
|
| 295 |
+
return 0
|
| 296 |
+
|
| 297 |
+
api_keys = load_api_keys()
|
| 298 |
+
hf_token = load_hf_token()
|
| 299 |
+
if not api_keys or not hf_token:
|
| 300 |
+
log.error("Missing API keys or HF token")
|
| 301 |
+
return 0
|
| 302 |
+
|
| 303 |
+
username = get_kaggle_username()
|
| 304 |
+
worker_code = WORKER_SCRIPT.read_text()
|
| 305 |
+
|
| 306 |
+
chunks = [pending[i:i+videos_per_worker] for i in range(0, len(pending), videos_per_worker)]
|
| 307 |
+
WORKERS_DIR.mkdir(parents=True, exist_ok=True)
|
| 308 |
+
keys_per_w = max(1, len(api_keys) // max(1, len(chunks)))
|
| 309 |
+
launched = 0
|
| 310 |
+
vmodels = load_vision_models()
|
| 311 |
+
|
| 312 |
+
for widx, chunk in enumerate(chunks):
|
| 313 |
+
worker_id = f"w-{uuid.uuid4().hex[:8]}"
|
| 314 |
+
kaggle_title = f"yt-w-{uuid.uuid4().hex[:6]}"
|
| 315 |
+
|
| 316 |
+
start_k = (widx * keys_per_w) % len(api_keys)
|
| 317 |
+
wkeys = [api_keys[(start_k+k) % len(api_keys)] for k in range(keys_per_w)]
|
| 318 |
+
|
| 319 |
+
video_list = [{"video_id":v["video_id"],"url":v["url"]} for v in chunk]
|
| 320 |
+
video_ids = [v["video_id"] for v in chunk]
|
| 321 |
+
|
| 322 |
+
config = {"worker_id":worker_id,"videos":video_list,"api_keys":wkeys,
|
| 323 |
+
"hf_token":hf_token,"vision_models":vmodels,
|
| 324 |
+
"text_model":"Qwen/Qwen3-32B"}
|
| 325 |
+
|
| 326 |
+
wdir = WORKERS_DIR / worker_id
|
| 327 |
+
wdir.mkdir(parents=True, exist_ok=True)
|
| 328 |
+
generate_notebook(wdir, config, worker_code)
|
| 329 |
+
generate_kernel_metadata(wdir, kaggle_title, username)
|
| 330 |
+
tracker.assign_batch(video_ids, worker_id)
|
| 331 |
+
tracker.register_worker(worker_id, kaggle_title, len(chunk))
|
| 332 |
+
|
| 333 |
+
log.info(f" Worker: {kaggle_title} — {len(chunk)} videos, {len(wkeys)} keys")
|
| 334 |
+
|
| 335 |
+
if push:
|
| 336 |
+
r = subprocess.run(["kaggle","kernels","push","-p",str(wdir)],
|
| 337 |
+
capture_output=True, text=True)
|
| 338 |
+
if r.returncode == 0:
|
| 339 |
+
tracker.mark_worker_pushed(worker_id)
|
| 340 |
+
launched += 1
|
| 341 |
+
log.info(f" ✅ Pushed")
|
| 342 |
+
else:
|
| 343 |
+
log.error(f" ❌ Push failed: {r.stderr[:200]}")
|
| 344 |
+
tracker.requeue_worker_videos(worker_id)
|
| 345 |
+
tracker.mark_worker(worker_id, WS_ERROR, "push_failed")
|
| 346 |
+
else:
|
| 347 |
+
launched += 1
|
| 348 |
+
|
| 349 |
+
return launched
|
| 350 |
+
|
| 351 |
+
# ── sync: poll kaggle + HF status ───────────────────────────────────
|
| 352 |
+
def sync_workers(tracker):
|
| 353 |
+
"""Check Kaggle status for all active workers, handle timeouts."""
|
| 354 |
+
username = get_kaggle_username()
|
| 355 |
+
active = tracker.pushed_workers()
|
| 356 |
+
if not active:
|
| 357 |
+
return
|
| 358 |
+
|
| 359 |
+
log.info(f"Checking {len(active)} active workers...")
|
| 360 |
+
|
| 361 |
+
# 1. Check for 12hr timeouts
|
| 362 |
+
timed_out = tracker.timed_out_workers()
|
| 363 |
+
for w in timed_out:
|
| 364 |
+
wid = w["worker_id"]
|
| 365 |
+
log.warning(f" ⏰ Worker {wid} ({w['kaggle_title']}) exceeded 12hr limit")
|
| 366 |
+
requeued = tracker.requeue_worker_videos(wid)
|
| 367 |
+
tracker.mark_worker(wid, WS_TIMEOUT, "12hr_timeout")
|
| 368 |
+
log.info(f" Requeued {requeued} videos back to pending")
|
| 369 |
+
|
| 370 |
+
# 2. Poll Kaggle API for each active worker
|
| 371 |
+
for w in active:
|
| 372 |
+
wid = w["worker_id"]
|
| 373 |
+
title = w["kaggle_title"]
|
| 374 |
+
if wid in [t["worker_id"] for t in timed_out]:
|
| 375 |
+
continue # already handled
|
| 376 |
+
|
| 377 |
+
kstatus = check_kaggle_kernel_status(title, username)
|
| 378 |
+
if not kstatus:
|
| 379 |
+
continue
|
| 380 |
+
|
| 381 |
+
if kstatus == "complete":
|
| 382 |
+
log.info(f" ✅ {title} completed")
|
| 383 |
+
tracker.mark_worker(wid, WS_COMPLETE, kstatus)
|
| 384 |
+
elif kstatus == "error":
|
| 385 |
+
log.warning(f" ❌ {title} errored")
|
| 386 |
+
requeued = tracker.requeue_worker_videos(wid)
|
| 387 |
+
tracker.mark_worker(wid, WS_ERROR, kstatus)
|
| 388 |
+
log.info(f" Requeued {requeued} videos")
|
| 389 |
+
elif kstatus == "cancelled":
|
| 390 |
+
log.warning(f" 🚫 {title} cancelled")
|
| 391 |
+
requeued = tracker.requeue_worker_videos(wid)
|
| 392 |
+
tracker.mark_worker(wid, WS_CANCELLED, kstatus)
|
| 393 |
+
log.info(f" Requeued {requeued} videos")
|
| 394 |
+
elif kstatus in ("running", "queued"):
|
| 395 |
+
tracker.mark_worker(wid, WS_RUNNING, kstatus)
|
| 396 |
+
time.sleep(1) # rate limit kaggle API
|
| 397 |
+
|
| 398 |
+
# ── sync HF status files to update video-level tracking ──────────────
|
| 399 |
+
def sync_hf_results(tracker):
|
| 400 |
+
hf_token = load_hf_token()
|
| 401 |
+
if not hf_token:
|
| 402 |
+
return
|
| 403 |
+
try:
|
| 404 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 405 |
+
api = HfApi(token=hf_token)
|
| 406 |
+
files = api.list_repo_files(repo_id=HF_REPO, repo_type="dataset")
|
| 407 |
+
|
| 408 |
+
status_files = [f for f in files if f.startswith("status/") and f.endswith(".json")]
|
| 409 |
+
data_files = [f for f in files if f.startswith("data/") and f.endswith(".parquet")]
|
| 410 |
+
|
| 411 |
+
for sf in status_files:
|
| 412 |
+
try:
|
| 413 |
+
local = hf_hub_download(repo_id=HF_REPO, filename=sf, repo_type="dataset",
|
| 414 |
+
token=hf_token, force_download=True)
|
| 415 |
+
with open(local) as f:
|
| 416 |
+
st = json.load(f)
|
| 417 |
+
wid = st.get("worker_id","")
|
| 418 |
+
state = st.get("state","")
|
| 419 |
+
v_status = st.get("video_status", {})
|
| 420 |
+
if v_status:
|
| 421 |
+
tracker.update_video_statuses(v_status)
|
| 422 |
+
log.info(f" HF status: {wid} → done={st.get('done',0)} "
|
| 423 |
+
f"failed={st.get('failed',0)} state={state}")
|
| 424 |
+
if state == "completed":
|
| 425 |
+
tracker.mark_worker(wid, WS_COMPLETE, "hf_confirmed")
|
| 426 |
+
except Exception as e:
|
| 427 |
+
log.warning(f" Could not read {sf}: {e}")
|
| 428 |
+
|
| 429 |
+
log.info(f"HF: {len(data_files)} parquet files, {len(status_files)} status files")
|
| 430 |
+
except Exception as e:
|
| 431 |
+
log.error(f"HF sync error: {e}")
|
| 432 |
+
|
| 433 |
+
# ── commands ─────────────────────────────────────────────────────────
|
| 434 |
+
def cmd_run(args):
|
| 435 |
+
"""Autopilot: continuously launch workers, sync, retry — respecting Kaggle limits."""
|
| 436 |
+
tracker = Tracker()
|
| 437 |
+
if not tracker.stats():
|
| 438 |
+
tracker.import_from_channels_db()
|
| 439 |
+
|
| 440 |
+
log.info(f"🚀 Autopilot started — max {MAX_KAGGLE_SESSIONS} sessions, "
|
| 441 |
+
f"{args.videos_per_worker} videos/worker, polling every {POLL_INTERVAL_SEC}s")
|
| 442 |
+
log.info(f" Press Ctrl+C to stop\n")
|
| 443 |
+
|
| 444 |
+
cycle = 0
|
| 445 |
+
while True:
|
| 446 |
+
cycle += 1
|
| 447 |
+
stats = tracker.stats()
|
| 448 |
+
pending = stats.get(S_PENDING, 0)
|
| 449 |
+
done = stats.get(S_DONE, 0)
|
| 450 |
+
total = sum(stats.values())
|
| 451 |
+
|
| 452 |
+
log.info(f"── Cycle {cycle} ───────────────��─────────────────")
|
| 453 |
+
log.info(f" Videos: {done}/{total} done, {pending} pending, "
|
| 454 |
+
f"{stats.get(S_ASSIGNED,0)} assigned, {stats.get(S_FAILED,0)} failed")
|
| 455 |
+
|
| 456 |
+
# 1. Sync — check which workers finished/failed/timed out
|
| 457 |
+
sync_workers(tracker)
|
| 458 |
+
|
| 459 |
+
# 2. Sync HF results
|
| 460 |
+
if cycle % 5 == 0: # every 5 cycles (~10min)
|
| 461 |
+
sync_hf_results(tracker)
|
| 462 |
+
|
| 463 |
+
# 3. Auto-retry failed (every 10 cycles)
|
| 464 |
+
if cycle % 10 == 0:
|
| 465 |
+
n = tracker.retry_failed(max_retries=3)
|
| 466 |
+
if n: log.info(f" Auto-retried {n} failed videos")
|
| 467 |
+
|
| 468 |
+
# 4. Launch new workers if slots available
|
| 469 |
+
slots = tracker.available_slots()
|
| 470 |
+
if slots > 0 and pending > 0:
|
| 471 |
+
log.info(f" {slots} Kaggle slots free — launching workers...")
|
| 472 |
+
n = launch_workers(tracker, slots, args.videos_per_worker, push=True)
|
| 473 |
+
log.info(f" Launched {n} workers")
|
| 474 |
+
elif slots == 0:
|
| 475 |
+
log.info(f" All {MAX_KAGGLE_SESSIONS} Kaggle slots in use — waiting...")
|
| 476 |
+
elif pending == 0:
|
| 477 |
+
assigned = stats.get(S_ASSIGNED, 0)
|
| 478 |
+
if assigned == 0:
|
| 479 |
+
log.info("✅ All videos processed! Exiting autopilot.")
|
| 480 |
+
break
|
| 481 |
+
log.info(f" Waiting for {assigned} assigned videos to complete...")
|
| 482 |
+
|
| 483 |
+
# 5. Sleep
|
| 484 |
+
log.info(f" Next check in {POLL_INTERVAL_SEC}s...\n")
|
| 485 |
+
try:
|
| 486 |
+
time.sleep(POLL_INTERVAL_SEC)
|
| 487 |
+
except KeyboardInterrupt:
|
| 488 |
+
log.info("\n⛔ Autopilot stopped by user")
|
| 489 |
+
break
|
| 490 |
+
|
| 491 |
+
def cmd_launch(args):
|
| 492 |
+
tracker = Tracker()
|
| 493 |
+
if not tracker.stats():
|
| 494 |
+
tracker.import_from_channels_db()
|
| 495 |
+
stats = tracker.stats()
|
| 496 |
+
log.info(f"Video stats: {dict(stats)}")
|
| 497 |
+
slots = tracker.available_slots()
|
| 498 |
+
log.info(f"Kaggle slots available: {slots}/{MAX_KAGGLE_SESSIONS}")
|
| 499 |
+
n = min(args.max_workers, slots)
|
| 500 |
+
if n <= 0:
|
| 501 |
+
log.error(f"No slots! {len(tracker.active_workers())} workers active.")
|
| 502 |
+
return
|
| 503 |
+
launched = launch_workers(tracker, n, args.videos_per_worker, push=args.push)
|
| 504 |
+
log.info(f"Launched {launched} workers" + (" (dry run)" if not args.push else ""))
|
| 505 |
+
|
| 506 |
+
def cmd_status(args):
|
| 507 |
+
tracker = Tracker()
|
| 508 |
+
stats = tracker.stats()
|
| 509 |
+
total = sum(stats.values()) or 1
|
| 510 |
+
active = tracker.active_workers()
|
| 511 |
+
slots = tracker.available_slots()
|
| 512 |
+
|
| 513 |
+
print(f"\n{'='*60}")
|
| 514 |
+
print(f" YT Pipeline Status")
|
| 515 |
+
print(f"{'='*60}")
|
| 516 |
+
print(f" Total videos: {total}")
|
| 517 |
+
print(f" Kaggle sessions: {len(active)}/{MAX_KAGGLE_SESSIONS} (slots free: {slots})")
|
| 518 |
+
print(f"{'─'*60}")
|
| 519 |
+
for s in [S_PENDING, S_ASSIGNED, S_DONE, S_FAILED, S_TIMEOUT]:
|
| 520 |
+
c = stats.get(s, 0)
|
| 521 |
+
pct = c/total*100
|
| 522 |
+
bar = "█"*int(pct/2) + "░"*(50-int(pct/2))
|
| 523 |
+
print(f" {s:12s} {c:7d} {pct:5.1f}% {bar}")
|
| 524 |
+
print(f"{'─'*60}")
|
| 525 |
+
|
| 526 |
+
workers = tracker.worker_stats()
|
| 527 |
+
if workers:
|
| 528 |
+
print(f"\n Recent Workers:")
|
| 529 |
+
for w in workers:
|
| 530 |
+
age = ""
|
| 531 |
+
if w["pushed_at"]:
|
| 532 |
+
try:
|
| 533 |
+
pushed = datetime.fromisoformat(w["pushed_at"])
|
| 534 |
+
hrs = (datetime.now(timezone.utc)-pushed).total_seconds()/3600
|
| 535 |
+
age = f" ({hrs:.1f}h ago)"
|
| 536 |
+
except: pass
|
| 537 |
+
ks = '?'
|
| 538 |
+
try: ks = w['kaggle_status'] or '?'
|
| 539 |
+
except: pass
|
| 540 |
+
print(f" {w['worker_id']} {w['kaggle_title']:25s} "
|
| 541 |
+
f"v={w['video_count']} {w['status']:10s} "
|
| 542 |
+
f"kaggle={ks}{age}")
|
| 543 |
+
print()
|
| 544 |
+
|
| 545 |
+
def cmd_retry(args):
|
| 546 |
+
tracker = Tracker()
|
| 547 |
+
n = tracker.retry_failed(max_retries=args.max_retries)
|
| 548 |
+
print(f"Reset {n} failed/timed-out videos to pending")
|
| 549 |
+
|
| 550 |
+
def cmd_sync(args):
|
| 551 |
+
tracker = Tracker()
|
| 552 |
+
sync_workers(tracker)
|
| 553 |
+
sync_hf_results(tracker)
|
| 554 |
+
|
| 555 |
+
def cmd_import(args):
|
| 556 |
+
tracker = Tracker()
|
| 557 |
+
n = tracker.import_from_channels_db()
|
| 558 |
+
print(f"Imported {n} videos")
|
| 559 |
+
|
| 560 |
+
# ── main ─────────────────────────────────────────────────────────────
|
| 561 |
+
def main():
|
| 562 |
+
p = argparse.ArgumentParser(description="YT Pipeline Orchestrator v3",
|
| 563 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 564 |
+
epilog="""
|
| 565 |
+
Commands:
|
| 566 |
+
run Autopilot — continuously launch, sync, retry (respects 10-session limit)
|
| 567 |
+
launch One-shot launch (respects slot limit)
|
| 568 |
+
status Show pipeline + Kaggle session status
|
| 569 |
+
sync Poll Kaggle API + HF for updates
|
| 570 |
+
retry Reset failed/timed-out videos for retry
|
| 571 |
+
import Import videos from channel_links.db
|
| 572 |
+
""")
|
| 573 |
+
sub = p.add_subparsers(dest="command")
|
| 574 |
+
|
| 575 |
+
pr = sub.add_parser("run", help="Autopilot loop")
|
| 576 |
+
pr.add_argument("--videos-per-worker", type=int, default=3)
|
| 577 |
+
|
| 578 |
+
pl = sub.add_parser("launch", help="One-shot launch")
|
| 579 |
+
pl.add_argument("--max-workers", type=int, default=5)
|
| 580 |
+
pl.add_argument("--videos-per-worker", type=int, default=3)
|
| 581 |
+
pl.add_argument("--push", action="store_true")
|
| 582 |
+
|
| 583 |
+
sub.add_parser("status", help="Show status")
|
| 584 |
+
|
| 585 |
+
pt = sub.add_parser("retry", help="Retry failed")
|
| 586 |
+
pt.add_argument("--max-retries", type=int, default=3)
|
| 587 |
+
|
| 588 |
+
sub.add_parser("sync", help="Sync from Kaggle+HF")
|
| 589 |
+
sub.add_parser("import", help="Import from channel_links.db")
|
| 590 |
+
|
| 591 |
+
args = p.parse_args()
|
| 592 |
+
if not args.command:
|
| 593 |
+
p.print_help(); return
|
| 594 |
+
|
| 595 |
+
{"run":cmd_run,"launch":cmd_launch,"status":cmd_status,
|
| 596 |
+
"retry":cmd_retry,"sync":cmd_sync,"import":cmd_import}[args.command](args)
|
| 597 |
+
|
| 598 |
+
if __name__ == "__main__":
|
| 599 |
+
main()
|
pipeline/worker.py
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
YT Video Analysis Worker v2 — Runs on Kaggle None runtime
|
| 4 |
+
Constraints: 30GB RAM · 20GB disk · 4 vCPUs · CPU-only
|
| 5 |
+
Pipeline: Download → Split Video 60s → Compress → Upload chunks to HF bucket
|
| 6 |
+
→ Vision model per chunk → Extract audio → Split if >3min
|
| 7 |
+
→ Transcribe → LLM analysis → Collect → Parquet → Upload dataset
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import base64, glob, hashlib, json, logging, os, re, shutil, subprocess
|
| 11 |
+
import sys, tempfile, time, traceback, uuid
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
logging.basicConfig(
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 18 |
+
datefmt="%H:%M:%S",
|
| 19 |
+
)
|
| 20 |
+
log = logging.getLogger("worker")
|
| 21 |
+
|
| 22 |
+
# ── constants ────────────────────────────────────────────────────────
|
| 23 |
+
HF_REPO = "AdhyanshVerma/YT"
|
| 24 |
+
STAGING_PREFIX = "staging"
|
| 25 |
+
DATA_PREFIX = "data"
|
| 26 |
+
VIDEO_CHUNK_SEC = 60
|
| 27 |
+
AUDIO_SPLIT_SEC = 180 # 3 minutes
|
| 28 |
+
MIN_TAIL_SEC = 15
|
| 29 |
+
FRAME_INTERVAL = 5 # extract 1 frame per N seconds
|
| 30 |
+
MAX_RETRIES = 3
|
| 31 |
+
RETRY_DELAY = 5
|
| 32 |
+
DISK_HEADROOM_GB = 4
|
| 33 |
+
|
| 34 |
+
# ── disk helpers ─────────────────────────────────────────────────────
|
| 35 |
+
def disk_free_gb(path="/"):
|
| 36 |
+
st = os.statvfs(path)
|
| 37 |
+
return (st.f_bavail * st.f_frsize) / (1024**3)
|
| 38 |
+
|
| 39 |
+
def safe_cleanup(*paths):
|
| 40 |
+
for p in paths:
|
| 41 |
+
try:
|
| 42 |
+
p = str(p)
|
| 43 |
+
if os.path.isdir(p):
|
| 44 |
+
shutil.rmtree(p, ignore_errors=True)
|
| 45 |
+
elif os.path.isfile(p):
|
| 46 |
+
os.remove(p)
|
| 47 |
+
except Exception:
|
| 48 |
+
pass
|
| 49 |
+
|
| 50 |
+
# ── ffprobe duration ─────────────────────────────────────────────────
|
| 51 |
+
def media_duration(path):
|
| 52 |
+
try:
|
| 53 |
+
r = subprocess.run(
|
| 54 |
+
["ffprobe","-v","error","-show_entries","format=duration",
|
| 55 |
+
"-of","default=noprint_wrappers=1:nokey=1", path],
|
| 56 |
+
capture_output=True, text=True, check=True)
|
| 57 |
+
return float(r.stdout.strip())
|
| 58 |
+
except Exception:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
# ── video download ───────────────────────────────────────────────────
|
| 62 |
+
def download_video(url, out_dir):
|
| 63 |
+
"""Download video at HD, return filepath or None."""
|
| 64 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 65 |
+
|
| 66 |
+
# Path to cookies file (useful for bypassing bot blocks)
|
| 67 |
+
# Checks either current working dir or the parent of the script
|
| 68 |
+
cookies_path = "cookies.txt"
|
| 69 |
+
if not os.path.exists(cookies_path):
|
| 70 |
+
# Safely get directory, falling back if __file__ is not defined (e.g. in notebooks)
|
| 71 |
+
try:
|
| 72 |
+
base_d = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 73 |
+
alt_cookies = os.path.join(base_d, "cookies.txt")
|
| 74 |
+
if os.path.exists(alt_cookies):
|
| 75 |
+
cookies_path = alt_cookies
|
| 76 |
+
except NameError:
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
opts = {
|
| 80 |
+
"format": "bestvideo[height<=1080]+bestaudio/best[height<=1080]/bestvideo+bestaudio/best",
|
| 81 |
+
"outtmpl": os.path.join(out_dir, "%(id)s.%(ext)s"),
|
| 82 |
+
"merge_output_format": "mp4",
|
| 83 |
+
"quiet": True, "no_warnings": True,
|
| 84 |
+
"nocheckcertificate": True, "ignoreerrors": False,
|
| 85 |
+
"geo_bypass": True,
|
| 86 |
+
"socket_timeout": 60,
|
| 87 |
+
"retries": 5,
|
| 88 |
+
"sleep_requests": 2,
|
| 89 |
+
"sleep_interval": 5,
|
| 90 |
+
"max_sleep_interval": 15,
|
| 91 |
+
"extractor_args": {"youtube": {"player_client": ["android", "ios", "tv", "web"]}},
|
| 92 |
+
"writeinfojson": True,
|
| 93 |
+
"writecomments": True,
|
| 94 |
+
"writesubtitles": True,
|
| 95 |
+
"subtitleslangs": ["en", "auto"],
|
| 96 |
+
"http_headers": {
|
| 97 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 98 |
+
"AppleWebKit/537.36 Chrome/125.0 Safari/537.36"
|
| 99 |
+
},
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
# if os.path.exists(cookies_path):
|
| 103 |
+
# opts["cookiefile"] = cookies_path
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
import yt_dlp
|
| 107 |
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
| 108 |
+
info = ydl.extract_info(url, download=True)
|
| 109 |
+
if not info:
|
| 110 |
+
return None, {}
|
| 111 |
+
fp = None
|
| 112 |
+
if "requested_downloads" in info:
|
| 113 |
+
fp = info["requested_downloads"][0]["filepath"]
|
| 114 |
+
else:
|
| 115 |
+
fp = ydl.prepare_filename(info)
|
| 116 |
+
fp = os.path.splitext(fp)[0] + ".mp4"
|
| 117 |
+
meta = {
|
| 118 |
+
"title": info.get("title",""),
|
| 119 |
+
"duration": info.get("duration",0),
|
| 120 |
+
"duration_string": info.get("duration_string",""),
|
| 121 |
+
"view_count": info.get("view_count",0),
|
| 122 |
+
"like_count": info.get("like_count",0),
|
| 123 |
+
"comment_count": info.get("comment_count",0),
|
| 124 |
+
"channel": info.get("channel",""),
|
| 125 |
+
"channel_id": info.get("channel_id",""),
|
| 126 |
+
"channel_url": info.get("channel_url",""),
|
| 127 |
+
"channel_follower_count": info.get("channel_follower_count",0),
|
| 128 |
+
"upload_date": info.get("upload_date",""),
|
| 129 |
+
"timestamp": info.get("timestamp",0),
|
| 130 |
+
"description": info.get("description",""),
|
| 131 |
+
"tags": info.get("tags",[]),
|
| 132 |
+
"categories": info.get("categories",[]),
|
| 133 |
+
"language": info.get("language",""),
|
| 134 |
+
"license": info.get("license",""),
|
| 135 |
+
"availability": info.get("availability",""),
|
| 136 |
+
"age_limit": info.get("age_limit",0),
|
| 137 |
+
"width": info.get("width",0),
|
| 138 |
+
"height": info.get("height",0),
|
| 139 |
+
"resolution": info.get("resolution",""),
|
| 140 |
+
"fps": info.get("fps",0),
|
| 141 |
+
"aspect_ratio": info.get("aspect_ratio",0),
|
| 142 |
+
"vcodec": info.get("vcodec",""),
|
| 143 |
+
"acodec": info.get("acodec",""),
|
| 144 |
+
"vbr": info.get("vbr",0),
|
| 145 |
+
"abr": info.get("abr",0),
|
| 146 |
+
"asr": info.get("asr",0),
|
| 147 |
+
"audio_channels": info.get("audio_channels",0),
|
| 148 |
+
"dynamic_range": info.get("dynamic_range",""),
|
| 149 |
+
"filesize_approx": info.get("filesize_approx",0),
|
| 150 |
+
"tbr": info.get("tbr",0),
|
| 151 |
+
"ext": info.get("ext",""),
|
| 152 |
+
"protocol": info.get("protocol",""),
|
| 153 |
+
"format_id": info.get("format_id",""),
|
| 154 |
+
"format_note": info.get("format_note",""),
|
| 155 |
+
"playlist_title": info.get("playlist_title",""),
|
| 156 |
+
"playlist_index": info.get("playlist_index",0),
|
| 157 |
+
"live_status": info.get("live_status",""),
|
| 158 |
+
"video_id": info.get("id",""),
|
| 159 |
+
"webpage_url": info.get("webpage_url", url),
|
| 160 |
+
}
|
| 161 |
+
return fp, meta
|
| 162 |
+
except Exception as e:
|
| 163 |
+
log.error(f"Download failed for {url}: {e}")
|
| 164 |
+
return None, {}
|
| 165 |
+
|
| 166 |
+
# ── video splitting ──────────────────────────────────────────────────
|
| 167 |
+
def split_video(input_path, chunk_sec=VIDEO_CHUNK_SEC, out_dir=None):
|
| 168 |
+
name = Path(input_path).stem
|
| 169 |
+
if out_dir is None:
|
| 170 |
+
out_dir = os.path.join(os.path.dirname(input_path), f"{name}_vchunks")
|
| 171 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 172 |
+
dur = media_duration(input_path)
|
| 173 |
+
if not dur:
|
| 174 |
+
return []
|
| 175 |
+
# calculate split points
|
| 176 |
+
pts, cur = [], chunk_sec
|
| 177 |
+
while cur < dur:
|
| 178 |
+
if (dur - cur) < MIN_TAIL_SEC:
|
| 179 |
+
break
|
| 180 |
+
pts.append(cur); cur += chunk_sec
|
| 181 |
+
pattern = os.path.join(out_dir, f"{name}_chunk_%03d.mp4")
|
| 182 |
+
cmd = ["ffmpeg","-y","-i",input_path,"-map","0","-c","copy",
|
| 183 |
+
"-f","segment","-reset_timestamps","1"]
|
| 184 |
+
if pts:
|
| 185 |
+
cmd += ["-segment_times", ",".join(str(round(p,3)) for p in pts)]
|
| 186 |
+
else:
|
| 187 |
+
cmd += ["-segment_time", str(dur+10)]
|
| 188 |
+
cmd.append(pattern)
|
| 189 |
+
try:
|
| 190 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 191 |
+
except subprocess.CalledProcessError as e:
|
| 192 |
+
log.error(f"Video split error: {e.stderr.decode(errors='replace')[:300]}")
|
| 193 |
+
return []
|
| 194 |
+
return sorted(glob.glob(os.path.join(out_dir, f"{name}_chunk_*.mp4")))
|
| 195 |
+
|
| 196 |
+
# ── video compression (same resolution, smaller size) ────────────────
|
| 197 |
+
def compress_chunk(inp, out):
|
| 198 |
+
"""Re-encode with h264 CRF 28 — same res, ~40-60% smaller."""
|
| 199 |
+
cmd = ["ffmpeg","-y","-i",inp,
|
| 200 |
+
"-c:v","libx264","-crf","28","-preset","fast",
|
| 201 |
+
"-c:a","aac","-b:a","128k", out]
|
| 202 |
+
try:
|
| 203 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 204 |
+
return True
|
| 205 |
+
except Exception:
|
| 206 |
+
# if compression fails, just copy
|
| 207 |
+
shutil.copy2(inp, out)
|
| 208 |
+
return False
|
| 209 |
+
|
| 210 |
+
# ── audio extraction ─────────────────────────────────────────────────
|
| 211 |
+
def extract_audio(video_path, out_path):
|
| 212 |
+
cmd = ["ffmpeg","-y","-i",video_path,
|
| 213 |
+
"-vn","-acodec","libmp3lame","-q:a","4", out_path]
|
| 214 |
+
try:
|
| 215 |
+
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
| 216 |
+
return True
|
| 217 |
+
except subprocess.CalledProcessError as e:
|
| 218 |
+
log.error(f"Audio extraction failed for {video_path}: {e.stderr}")
|
| 219 |
+
return False
|
| 220 |
+
except Exception as e:
|
| 221 |
+
log.error(f"Unexpected error in audio extraction: {e}")
|
| 222 |
+
return False
|
| 223 |
+
|
| 224 |
+
# ── audio silence detection + splitting ──────────────────────────────
|
| 225 |
+
def detect_silences(audio_path):
|
| 226 |
+
cmd = ["ffmpeg","-i",audio_path,
|
| 227 |
+
"-af","silencedetect=noise=-20dB:d=0.3","-f","null","-"]
|
| 228 |
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
| 229 |
+
starts, ends = [], []
|
| 230 |
+
for line in r.stderr.split("\n"):
|
| 231 |
+
m = re.search(r"silence_start:\s+([\d.]+)", line)
|
| 232 |
+
if m: starts.append(float(m.group(1)))
|
| 233 |
+
m = re.search(r"silence_end:\s+([\d.]+)", line)
|
| 234 |
+
if m: ends.append(float(m.group(1)))
|
| 235 |
+
return [(s+e)/2 for s,e in zip(starts, ends)]
|
| 236 |
+
|
| 237 |
+
def split_audio(audio_path, chunk_sec=AUDIO_SPLIT_SEC, out_dir=None):
|
| 238 |
+
name = Path(audio_path).stem
|
| 239 |
+
if out_dir is None:
|
| 240 |
+
out_dir = os.path.join(os.path.dirname(audio_path), f"{name}_achunks")
|
| 241 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 242 |
+
dur = media_duration(audio_path)
|
| 243 |
+
if not dur or dur <= chunk_sec:
|
| 244 |
+
return [audio_path] # no split needed
|
| 245 |
+
silences = detect_silences(audio_path)
|
| 246 |
+
pts, target = [], chunk_sec
|
| 247 |
+
while target < dur:
|
| 248 |
+
if (dur - target) < MIN_TAIL_SEC:
|
| 249 |
+
break
|
| 250 |
+
valid = [p for p in silences if abs(p - target) <= 15]
|
| 251 |
+
if valid:
|
| 252 |
+
best = min(valid, key=lambda p: abs(p - target))
|
| 253 |
+
pts.append(best); target = best + chunk_sec
|
| 254 |
+
else:
|
| 255 |
+
pts.append(target); target += chunk_sec
|
| 256 |
+
ext = Path(audio_path).suffix
|
| 257 |
+
pattern = os.path.join(out_dir, f"{name}_achunk_%03d{ext}")
|
| 258 |
+
cmd = ["ffmpeg","-y","-i",audio_path,"-c","copy","-map","0",
|
| 259 |
+
"-f","segment","-reset_timestamps","1"]
|
| 260 |
+
if pts:
|
| 261 |
+
cmd += ["-segment_times", ",".join(str(round(p,3)) for p in pts)]
|
| 262 |
+
else:
|
| 263 |
+
cmd += ["-segment_time", str(dur+10)]
|
| 264 |
+
cmd.append(pattern)
|
| 265 |
+
try:
|
| 266 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 267 |
+
return sorted(glob.glob(os.path.join(out_dir, f"{name}_achunk_*{ext}")))
|
| 268 |
+
except Exception:
|
| 269 |
+
return [audio_path]
|
| 270 |
+
|
| 271 |
+
# ── frame extraction for vision API ──────────────────────────────────
|
| 272 |
+
def extract_frames(video_path, out_dir, interval=FRAME_INTERVAL):
|
| 273 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 274 |
+
pattern = os.path.join(out_dir, "frame_%04d.jpg")
|
| 275 |
+
cmd = ["ffmpeg","-y","-i",video_path,"-vf",f"fps=1/{interval}",
|
| 276 |
+
"-q:v","5", pattern]
|
| 277 |
+
try:
|
| 278 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 279 |
+
return sorted(glob.glob(os.path.join(out_dir, "frame_*.jpg")))
|
| 280 |
+
except Exception:
|
| 281 |
+
return []
|
| 282 |
+
|
| 283 |
+
def encode_image_b64(path):
|
| 284 |
+
with open(path, "rb") as f:
|
| 285 |
+
return "data:image/jpeg;base64," + base64.b64encode(f.read()).decode()
|
| 286 |
+
|
| 287 |
+
# ── HF storage bucket ───────────────────────────────────────────────
|
| 288 |
+
def hf_upload_file(local_path, remote_path, token):
|
| 289 |
+
from huggingface_hub import HfApi
|
| 290 |
+
api = HfApi(token=token)
|
| 291 |
+
for attempt in range(MAX_RETRIES):
|
| 292 |
+
try:
|
| 293 |
+
api.upload_file(
|
| 294 |
+
path_or_fileobj=local_path,
|
| 295 |
+
path_in_repo=remote_path,
|
| 296 |
+
repo_id=HF_REPO, repo_type="dataset",
|
| 297 |
+
)
|
| 298 |
+
return True
|
| 299 |
+
except Exception as e:
|
| 300 |
+
log.warning(f"HF upload attempt {attempt+1} failed: {e}")
|
| 301 |
+
time.sleep(RETRY_DELAY * (attempt+1))
|
| 302 |
+
return False
|
| 303 |
+
|
| 304 |
+
def hf_download_url(remote_path):
|
| 305 |
+
return f"https://huggingface.co/datasets/{HF_REPO}/resolve/main/{remote_path}"
|
| 306 |
+
|
| 307 |
+
def hf_delete_folder(prefix, token):
|
| 308 |
+
from huggingface_hub import HfApi
|
| 309 |
+
api = HfApi(token=token)
|
| 310 |
+
try:
|
| 311 |
+
files = api.list_repo_files(repo_id=HF_REPO, repo_type="dataset")
|
| 312 |
+
to_del = [f for f in files if f.startswith(prefix)]
|
| 313 |
+
if to_del:
|
| 314 |
+
ops = []
|
| 315 |
+
from huggingface_hub import CommitOperationDelete
|
| 316 |
+
for f in to_del:
|
| 317 |
+
ops.append(CommitOperationDelete(path_in_repo=f))
|
| 318 |
+
api.create_commit(
|
| 319 |
+
repo_id=HF_REPO, repo_type="dataset",
|
| 320 |
+
operations=ops, commit_message=f"cleanup {prefix}")
|
| 321 |
+
except Exception as e:
|
| 322 |
+
log.warning(f"HF cleanup failed: {e}")
|
| 323 |
+
|
| 324 |
+
# ── model API calls ──────────────────────────────────────────────────
|
| 325 |
+
def _api_call(messages, api_key, model, base_url="https://api.featherless.ai/v1",
|
| 326 |
+
max_tokens=4096, temperature=0.4):
|
| 327 |
+
"""OpenAI-compatible API call with retries."""
|
| 328 |
+
from openai import OpenAI
|
| 329 |
+
client = OpenAI(base_url=base_url, api_key=api_key)
|
| 330 |
+
for attempt in range(MAX_RETRIES):
|
| 331 |
+
try:
|
| 332 |
+
resp = client.chat.completions.create(
|
| 333 |
+
model=model, messages=messages,
|
| 334 |
+
max_tokens=max_tokens, temperature=temperature)
|
| 335 |
+
return resp.choices[0].message.content
|
| 336 |
+
except Exception as e:
|
| 337 |
+
log.warning(f"API attempt {attempt+1} failed ({model}): {e}")
|
| 338 |
+
time.sleep(RETRY_DELAY * (attempt+1))
|
| 339 |
+
return None
|
| 340 |
+
|
| 341 |
+
DEFAULT_VISION_MODELS = [
|
| 342 |
+
"MiniMaxAI/MiniMax-M3",
|
| 343 |
+
"XiaomiMiMo/MiMo-V2.5",
|
| 344 |
+
"Qwen/Qwen3-VL-32B-Instruct",
|
| 345 |
+
"Qwen/Qwen3.6-27B",
|
| 346 |
+
"Qwen/Qwen3-VL-235B-A22B-Thinking",
|
| 347 |
+
]
|
| 348 |
+
|
| 349 |
+
def analyze_video_chunk(frames_dir, chunk_idx, total_chunks, api_key,
|
| 350 |
+
vision_models=None):
|
| 351 |
+
"""Send extracted frames to vision model for analysis with priority fallback."""
|
| 352 |
+
frames = sorted(glob.glob(os.path.join(frames_dir, "frame_*.jpg")))
|
| 353 |
+
if not frames:
|
| 354 |
+
return None
|
| 355 |
+
prompt = (
|
| 356 |
+
f"You are analyzing video chunk {chunk_idx+1}/{total_chunks}. "
|
| 357 |
+
f"These are frames extracted every {FRAME_INTERVAL}s from a 60-second segment.\n\n"
|
| 358 |
+
"Provide an EXTREMELY detailed analysis:\n"
|
| 359 |
+
"1. Scene description — what is happening visually\n"
|
| 360 |
+
"2. Objects, people, text/UI visible on screen\n"
|
| 361 |
+
"3. Motion and transitions between frames\n"
|
| 362 |
+
"4. Any on-screen text, code, diagrams, or slides\n"
|
| 363 |
+
"5. Visual style, colors, camera angles\n"
|
| 364 |
+
"6. Key information conveyed visually\n\n"
|
| 365 |
+
"Be thorough — every detail matters for the dataset."
|
| 366 |
+
)
|
| 367 |
+
content = [{"type": "text", "text": prompt}]
|
| 368 |
+
# send up to 12 frames to avoid token limits
|
| 369 |
+
step = max(1, len(frames) // 12)
|
| 370 |
+
selected = frames[::step][:12]
|
| 371 |
+
for fp in selected:
|
| 372 |
+
content.append({
|
| 373 |
+
"type": "image_url",
|
| 374 |
+
"image_url": {"url": encode_image_b64(fp)}
|
| 375 |
+
})
|
| 376 |
+
messages = [{"role": "user", "content": content}]
|
| 377 |
+
|
| 378 |
+
models = vision_models if vision_models else DEFAULT_VISION_MODELS
|
| 379 |
+
for model in models:
|
| 380 |
+
log.info(f"Attempting vision analysis with model: {model}")
|
| 381 |
+
res = _api_call(messages, api_key, model, max_tokens=4096)
|
| 382 |
+
if res:
|
| 383 |
+
log.info(f"Vision analysis succeeded with model: {model}")
|
| 384 |
+
return res
|
| 385 |
+
log.warning(f"Vision model {model} failed or busy. Trying next in priority...")
|
| 386 |
+
return None
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def analyze_audio_transcript(transcript, chunk_idx, total_chunks, api_key,
|
| 390 |
+
model="Qwen/Qwen3-32B"):
|
| 391 |
+
"""Send audio transcript to LLM for detailed analysis."""
|
| 392 |
+
prompt = (
|
| 393 |
+
f"Audio transcript chunk {chunk_idx+1}/{total_chunks}:\n\n"
|
| 394 |
+
f"---\n{transcript}\n---\n\n"
|
| 395 |
+
"Provide a detailed analysis of this audio:\n"
|
| 396 |
+
"1. Summary of what is being discussed\n"
|
| 397 |
+
"2. Key points and arguments made\n"
|
| 398 |
+
"3. Technical terms or concepts mentioned\n"
|
| 399 |
+
"4. Speaker tone and intent\n"
|
| 400 |
+
"5. Notable quotes or statements\n"
|
| 401 |
+
"6. Questions raised or answered\n\n"
|
| 402 |
+
"Be comprehensive and precise."
|
| 403 |
+
)
|
| 404 |
+
messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}]
|
| 405 |
+
return _api_call(messages, api_key, model, max_tokens=4096)
|
| 406 |
+
|
| 407 |
+
def transcribe_audio_chunk(audio_path):
|
| 408 |
+
"""Transcribe audio using faster-whisper (CPU)."""
|
| 409 |
+
try:
|
| 410 |
+
from faster_whisper import WhisperModel
|
| 411 |
+
model = WhisperModel("base", device="cpu", compute_type="int8")
|
| 412 |
+
segments, info = model.transcribe(audio_path, beam_size=3)
|
| 413 |
+
text_parts = []
|
| 414 |
+
for seg in segments:
|
| 415 |
+
text_parts.append(f"[{seg.start:.1f}s-{seg.end:.1f}s] {seg.text.strip()}")
|
| 416 |
+
return "\n".join(text_parts)
|
| 417 |
+
except Exception as e:
|
| 418 |
+
log.error(f"Transcription failed: {e}")
|
| 419 |
+
return None
|
| 420 |
+
|
| 421 |
+
# ── per-video pipeline ───────────────────────────────────────────────
|
| 422 |
+
def process_video(video_url, video_id, api_key, hf_token, worker_id, work_dir,
|
| 423 |
+
vision_models=None):
|
| 424 |
+
"""Full pipeline for one video. Returns result dict or None."""
|
| 425 |
+
log.info(f"{'='*60}")
|
| 426 |
+
log.info(f"Processing: {video_id} — {video_url}")
|
| 427 |
+
log.info(f"Disk free: {disk_free_gb():.1f} GB")
|
| 428 |
+
|
| 429 |
+
vid_dir = os.path.join(work_dir, video_id)
|
| 430 |
+
os.makedirs(vid_dir, exist_ok=True)
|
| 431 |
+
staging_prefix = f"{STAGING_PREFIX}/{worker_id}/{video_id}"
|
| 432 |
+
|
| 433 |
+
result = {
|
| 434 |
+
"video_id": video_id,
|
| 435 |
+
"url": video_url,
|
| 436 |
+
"worker_id": worker_id,
|
| 437 |
+
"processed_at": datetime.now(timezone.utc).isoformat(),
|
| 438 |
+
"status": "failed",
|
| 439 |
+
"video_analysis": [],
|
| 440 |
+
"audio_analysis": [],
|
| 441 |
+
"audio_transcript": "",
|
| 442 |
+
"metadata": {},
|
| 443 |
+
"hf_video_urls": [],
|
| 444 |
+
"errors": [],
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
try:
|
| 448 |
+
# ── 1. Download ──────────────────────────────────────────
|
| 449 |
+
log.info("Step 1: Downloading video...")
|
| 450 |
+
dl_dir = os.path.join(vid_dir, "download")
|
| 451 |
+
video_path, meta = download_video(video_url, dl_dir)
|
| 452 |
+
if not video_path or not os.path.exists(video_path):
|
| 453 |
+
log.error(f"Download failed for {video_id}. Skipping commit to avoid empty records.")
|
| 454 |
+
return None
|
| 455 |
+
result["metadata"] = meta
|
| 456 |
+
fsize = os.path.getsize(video_path) / (1024**2)
|
| 457 |
+
log.info(f"Downloaded: {fsize:.0f} MB — {meta.get('title','?')}")
|
| 458 |
+
|
| 459 |
+
# ── 2. Split video into 60s chunks (skip for short videos) ──
|
| 460 |
+
vid_duration = media_duration(video_path) or meta.get("duration", 0)
|
| 461 |
+
if vid_duration and vid_duration <= VIDEO_CHUNK_SEC + MIN_TAIL_SEC:
|
| 462 |
+
log.info(f"Step 2: Short video ({vid_duration:.0f}s ≤ {VIDEO_CHUNK_SEC}s) — skipping split")
|
| 463 |
+
video_chunks = [video_path]
|
| 464 |
+
else:
|
| 465 |
+
log.info(f"Step 2: Splitting {vid_duration:.0f}s video into {VIDEO_CHUNK_SEC}s chunks...")
|
| 466 |
+
chunks_dir = os.path.join(vid_dir, "vchunks")
|
| 467 |
+
video_chunks = split_video(video_path, VIDEO_CHUNK_SEC, chunks_dir)
|
| 468 |
+
if not video_chunks:
|
| 469 |
+
video_chunks = [video_path]
|
| 470 |
+
log.info(f"Video chunks: {len(video_chunks)}")
|
| 471 |
+
|
| 472 |
+
# ── 3. Compress + upload chunks + vision analysis ────────
|
| 473 |
+
log.info("Step 3: Compress → Upload → Vision analysis...")
|
| 474 |
+
for cidx, chunk_path in enumerate(video_chunks):
|
| 475 |
+
log.info(f" Chunk {cidx+1}/{len(video_chunks)}: {Path(chunk_path).name}")
|
| 476 |
+
# compress
|
| 477 |
+
comp_path = chunk_path.replace(".mp4", "_comp.mp4")
|
| 478 |
+
compress_chunk(chunk_path, comp_path)
|
| 479 |
+
if os.path.exists(comp_path):
|
| 480 |
+
if chunk_path != video_path:
|
| 481 |
+
safe_cleanup(chunk_path)
|
| 482 |
+
chunk_path = comp_path
|
| 483 |
+
|
| 484 |
+
# upload to HF bucket
|
| 485 |
+
remote = f"{staging_prefix}/v_chunk_{cidx:03d}.mp4"
|
| 486 |
+
uploaded = hf_upload_file(chunk_path, remote, hf_token)
|
| 487 |
+
if not uploaded:
|
| 488 |
+
result["errors"].append(f"Upload failed chunk {cidx}")
|
| 489 |
+
if chunk_path != video_path:
|
| 490 |
+
safe_cleanup(chunk_path)
|
| 491 |
+
continue
|
| 492 |
+
|
| 493 |
+
# extract frames for vision API
|
| 494 |
+
result["hf_video_urls"].append(hf_download_url(remote))
|
| 495 |
+
frames_dir = os.path.join(vid_dir, f"frames_{cidx}")
|
| 496 |
+
frames = extract_frames(chunk_path, frames_dir)
|
| 497 |
+
if chunk_path != video_path:
|
| 498 |
+
safe_cleanup(chunk_path) # free disk
|
| 499 |
+
|
| 500 |
+
if frames:
|
| 501 |
+
analysis = analyze_video_chunk(
|
| 502 |
+
frames_dir, cidx, len(video_chunks), api_key, vision_models)
|
| 503 |
+
result["video_analysis"].append({
|
| 504 |
+
"chunk_index": cidx,
|
| 505 |
+
"chunk_url": hf_download_url(remote),
|
| 506 |
+
"analysis": analysis or "ANALYSIS_FAILED",
|
| 507 |
+
|
| 508 |
+
"frame_count": len(frames),
|
| 509 |
+
})
|
| 510 |
+
safe_cleanup(frames_dir)
|
| 511 |
+
|
| 512 |
+
# ── 4. Extract audio ─────────────────────────────────────
|
| 513 |
+
log.info("Step 4: Extracting audio...")
|
| 514 |
+
audio_path = os.path.join(vid_dir, f"{video_id}.mp3")
|
| 515 |
+
audio_ok = extract_audio(video_path, audio_path)
|
| 516 |
+
# We no longer delete the original video here so it can be stored in the scalable directory system
|
| 517 |
+
# safe_cleanup(video_path, dl_dir)
|
| 518 |
+
|
| 519 |
+
if audio_ok and os.path.exists(audio_path):
|
| 520 |
+
audio_dur = media_duration(audio_path)
|
| 521 |
+
log.info(f"Audio duration: {audio_dur:.0f}s")
|
| 522 |
+
|
| 523 |
+
# ── 5. Split audio if > 3 min ────────────────────────
|
| 524 |
+
if audio_dur and audio_dur > AUDIO_SPLIT_SEC:
|
| 525 |
+
log.info("Step 5: Splitting audio (>3min)...")
|
| 526 |
+
audio_chunks = split_audio(audio_path, AUDIO_SPLIT_SEC)
|
| 527 |
+
if audio_chunks and audio_chunks[0] != audio_path:
|
| 528 |
+
safe_cleanup(audio_path)
|
| 529 |
+
else:
|
| 530 |
+
audio_chunks = [audio_path]
|
| 531 |
+
log.info(f"Audio chunks: {len(audio_chunks)}")
|
| 532 |
+
|
| 533 |
+
# ── 6. Transcribe + analyze each audio chunk ─────────
|
| 534 |
+
log.info("Step 6: Transcribing + analyzing audio...")
|
| 535 |
+
all_transcript = []
|
| 536 |
+
for aidx, achunk in enumerate(audio_chunks):
|
| 537 |
+
log.info(f" Audio chunk {aidx+1}/{len(audio_chunks)}")
|
| 538 |
+
transcript = transcribe_audio_chunk(achunk)
|
| 539 |
+
if transcript:
|
| 540 |
+
all_transcript.append(transcript)
|
| 541 |
+
analysis = analyze_audio_transcript(
|
| 542 |
+
transcript, aidx, len(audio_chunks), api_key)
|
| 543 |
+
result["audio_analysis"].append({
|
| 544 |
+
"chunk_index": aidx,
|
| 545 |
+
"transcript": transcript,
|
| 546 |
+
"analysis": analysis or "ANALYSIS_FAILED",
|
| 547 |
+
})
|
| 548 |
+
safe_cleanup(achunk)
|
| 549 |
+
result["audio_transcript"] = "\n\n".join(all_transcript)
|
| 550 |
+
else:
|
| 551 |
+
result["errors"].append("Audio extraction failed")
|
| 552 |
+
|
| 553 |
+
# ── 7. Mark success and Store in Scalable Directory ──────────
|
| 554 |
+
if result["video_analysis"] or result["audio_analysis"]:
|
| 555 |
+
result["status"] = "done"
|
| 556 |
+
log.info(f"Video {video_id} → {result['status']}")
|
| 557 |
+
|
| 558 |
+
# Move to scalable directory system
|
| 559 |
+
channel_id = result.get("metadata", {}).get("channel_id", "unknown_channel")
|
| 560 |
+
store_dir = os.path.join(os.path.dirname(work_dir), "video_store", channel_id, video_id)
|
| 561 |
+
os.makedirs(store_dir, exist_ok=True)
|
| 562 |
+
|
| 563 |
+
# Copy original video and yt-dlp metadata to store_dir
|
| 564 |
+
if os.path.exists(dl_dir):
|
| 565 |
+
for f in glob.glob(os.path.join(dl_dir, "*")):
|
| 566 |
+
shutil.copy2(f, store_dir)
|
| 567 |
+
|
| 568 |
+
# Save AI generated descriptions
|
| 569 |
+
ai_desc_path = os.path.join(store_dir, "ai_description.json")
|
| 570 |
+
with open(ai_desc_path, "w", encoding="utf-8") as f:
|
| 571 |
+
json.dump({
|
| 572 |
+
"video_analysis": result.get("video_analysis", []),
|
| 573 |
+
"audio_analysis": result.get("audio_analysis", []),
|
| 574 |
+
"audio_transcript": result.get("audio_transcript", "")
|
| 575 |
+
}, f, indent=2, ensure_ascii=False)
|
| 576 |
+
|
| 577 |
+
except Exception as e:
|
| 578 |
+
log.error(f"Pipeline failed for {video_id}: {e}")
|
| 579 |
+
result = None
|
| 580 |
+
finally:
|
| 581 |
+
safe_cleanup(vid_dir)
|
| 582 |
+
# clean HF staging
|
| 583 |
+
try:
|
| 584 |
+
# We skip HF deletion if we actually want to keep it on HF as requested
|
| 585 |
+
# hf_delete_folder(staging_prefix, hf_token)
|
| 586 |
+
pass
|
| 587 |
+
except Exception:
|
| 588 |
+
pass
|
| 589 |
+
|
| 590 |
+
return result
|
| 591 |
+
|
| 592 |
+
# ── results → parquet + upload ───────────────────────────────────────
|
| 593 |
+
def save_and_upload(results, worker_id, hf_token, work_dir):
|
| 594 |
+
"""Save results as parquet and upload to HF dataset."""
|
| 595 |
+
import pyarrow as pa
|
| 596 |
+
import pyarrow.parquet as pq
|
| 597 |
+
|
| 598 |
+
if not results:
|
| 599 |
+
log.warning("No results to save")
|
| 600 |
+
return False
|
| 601 |
+
|
| 602 |
+
rows = []
|
| 603 |
+
for r in results:
|
| 604 |
+
rows.append({
|
| 605 |
+
"video_id": r["video_id"],
|
| 606 |
+
"url": r["url"],
|
| 607 |
+
"title": r.get("metadata",{}).get("title",""),
|
| 608 |
+
"channel": r.get("metadata",{}).get("channel",""),
|
| 609 |
+
"duration": r.get("metadata",{}).get("duration",0),
|
| 610 |
+
"view_count": r.get("metadata",{}).get("view_count",0),
|
| 611 |
+
"upload_date": r.get("metadata",{}).get("upload_date",""),
|
| 612 |
+
"description": r.get("metadata",{}).get("description",""),
|
| 613 |
+
"video_analysis": json.dumps(r.get("video_analysis",[])),
|
| 614 |
+
"audio_transcript": r.get("audio_transcript",""),
|
| 615 |
+
"audio_analysis": json.dumps(r.get("audio_analysis",[])),
|
| 616 |
+
"hf_video_urls": json.dumps(r.get("hf_video_urls",[])),
|
| 617 |
+
"status": r["status"],
|
| 618 |
+
"errors": json.dumps(r.get("errors",[])),
|
| 619 |
+
"worker_id": r["worker_id"],
|
| 620 |
+
"processed_at": r["processed_at"],
|
| 621 |
+
})
|
| 622 |
+
|
| 623 |
+
table = pa.table({k: [row[k] for row in rows] for k in rows[0]})
|
| 624 |
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
| 625 |
+
fname = f"{worker_id}_{ts}.parquet"
|
| 626 |
+
local_pq = os.path.join(work_dir, fname)
|
| 627 |
+
pq.write_table(table, local_pq, compression="zstd")
|
| 628 |
+
log.info(f"Saved {len(rows)} rows → {fname} ({os.path.getsize(local_pq)/1024:.0f} KB)")
|
| 629 |
+
|
| 630 |
+
remote = f"{DATA_PREFIX}/{fname}"
|
| 631 |
+
ok = hf_upload_file(local_pq, remote, hf_token)
|
| 632 |
+
safe_cleanup(local_pq)
|
| 633 |
+
if ok:
|
| 634 |
+
log.info(f"✅ Uploaded to HF: {remote}")
|
| 635 |
+
else:
|
| 636 |
+
log.error("❌ Failed to upload parquet")
|
| 637 |
+
return ok
|
| 638 |
+
|
| 639 |
+
# ── status reporting ─────────────────────────────────────────────────
|
| 640 |
+
def report_status(worker_id, status_data, hf_token, work_dir):
|
| 641 |
+
fp = os.path.join(work_dir, "status.json")
|
| 642 |
+
with open(fp, "w") as f:
|
| 643 |
+
json.dump(status_data, f, indent=2)
|
| 644 |
+
hf_upload_file(fp, f"status/{worker_id}.json", hf_token)
|
| 645 |
+
safe_cleanup(fp)
|
| 646 |
+
|
| 647 |
+
# ── main entry ───────────────────────────────────────────────────────
|
| 648 |
+
def run_worker(config):
|
| 649 |
+
"""
|
| 650 |
+
config = {
|
| 651 |
+
"worker_id": "w-abc123",
|
| 652 |
+
"videos": [{"video_id": "xxx", "url": "https://..."}],
|
| 653 |
+
"api_keys": ["key1", "key2", ...],
|
| 654 |
+
"hf_token": "hf_xxx",
|
| 655 |
+
"vision_model": "Qwen/Qwen3-VL-32B-Instruct",
|
| 656 |
+
"text_model": "Qwen/Qwen3-32B",
|
| 657 |
+
}
|
| 658 |
+
"""
|
| 659 |
+
worker_id = config["worker_id"]
|
| 660 |
+
videos = config["videos"]
|
| 661 |
+
api_keys = config["api_keys"]
|
| 662 |
+
hf_token = config["hf_token"]
|
| 663 |
+
vision_models = config.get("vision_models")
|
| 664 |
+
|
| 665 |
+
work_dir = os.path.join(
|
| 666 |
+
os.environ.get("KAGGLE_WORKING_DIR", "/kaggle/working"), "yt_pipeline")
|
| 667 |
+
os.makedirs(work_dir, exist_ok=True)
|
| 668 |
+
|
| 669 |
+
log.info(f"Worker {worker_id} starting — {len(videos)} videos, "
|
| 670 |
+
f"{len(api_keys)} API keys, disk free: {disk_free_gb():.1f}GB")
|
| 671 |
+
|
| 672 |
+
results = []
|
| 673 |
+
status = {
|
| 674 |
+
"worker_id": worker_id,
|
| 675 |
+
"total": len(videos),
|
| 676 |
+
"done": 0, "failed": 0,
|
| 677 |
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
| 678 |
+
"state": "running",
|
| 679 |
+
"video_status": {},
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
for vidx, vinfo in enumerate(videos):
|
| 683 |
+
vid_id = vinfo["video_id"]
|
| 684 |
+
vid_url = vinfo["url"]
|
| 685 |
+
api_key = api_keys[vidx % len(api_keys)] # round-robin
|
| 686 |
+
|
| 687 |
+
log.info(f"\n[{vidx+1}/{len(videos)}] {vid_id}")
|
| 688 |
+
|
| 689 |
+
# check disk
|
| 690 |
+
if disk_free_gb() < DISK_HEADROOM_GB:
|
| 691 |
+
log.error("Disk too full, stopping early")
|
| 692 |
+
status["state"] = "disk_full"
|
| 693 |
+
break
|
| 694 |
+
|
| 695 |
+
result = process_video(vid_url, vid_id, api_key, hf_token,
|
| 696 |
+
worker_id, work_dir, vision_models=vision_models)
|
| 697 |
+
|
| 698 |
+
if result is None:
|
| 699 |
+
status["failed"] += 1
|
| 700 |
+
status["video_status"][vid_id] = "failed"
|
| 701 |
+
else:
|
| 702 |
+
results.append(result)
|
| 703 |
+
status["video_status"][vid_id] = result["status"]
|
| 704 |
+
if result["status"] == "done":
|
| 705 |
+
status["done"] += 1
|
| 706 |
+
else:
|
| 707 |
+
status["failed"] += 1
|
| 708 |
+
|
| 709 |
+
# periodic save + status report
|
| 710 |
+
if (vidx + 1) % 10 == 0 or vidx == len(videos) - 1:
|
| 711 |
+
save_and_upload(results, worker_id, hf_token, work_dir)
|
| 712 |
+
results = [] # reset batch
|
| 713 |
+
status["last_update"] = datetime.now(timezone.utc).isoformat()
|
| 714 |
+
report_status(worker_id, status, hf_token, work_dir)
|
| 715 |
+
|
| 716 |
+
# final save
|
| 717 |
+
if results:
|
| 718 |
+
save_and_upload(results, worker_id, hf_token, work_dir)
|
| 719 |
+
|
| 720 |
+
status["state"] = "completed"
|
| 721 |
+
status["finished_at"] = datetime.now(timezone.utc).isoformat()
|
| 722 |
+
report_status(worker_id, status, hf_token, work_dir)
|
| 723 |
+
log.info(f"Worker {worker_id} finished: {status['done']} done, {status['failed']} failed")
|
| 724 |
+
return status
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
def _is_notebook():
|
| 728 |
+
"""Detect if running inside Jupyter/Kaggle notebook."""
|
| 729 |
+
try:
|
| 730 |
+
return "ipykernel" in sys.modules or any("-f" in a for a in sys.argv)
|
| 731 |
+
except Exception:
|
| 732 |
+
return False
|
| 733 |
+
|
| 734 |
+
if __name__ == "__main__" and not _is_notebook():
|
| 735 |
+
# For local testing only — skipped on Kaggle
|
| 736 |
+
if len(sys.argv) > 1:
|
| 737 |
+
with open(sys.argv[1]) as f:
|
| 738 |
+
cfg = json.load(f)
|
| 739 |
+
run_worker(cfg)
|
| 740 |
+
else:
|
| 741 |
+
print("Usage: python worker.py config.json")
|
worker/db_worker/get_apis.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
def print_db_data(db_path):
|
| 3 |
+
# Connect to the SQLite database
|
| 4 |
+
conn = sqlite3.connect(db_path)
|
| 5 |
+
cursor = conn.cursor()
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
# Execute a query to read standard data columns
|
| 9 |
+
cursor.execute("SELECT id, email, api_key, created_at FROM accounts")
|
| 10 |
+
rows = cursor.fetchall()
|
| 11 |
+
|
| 12 |
+
print(f"Found {len(rows)} records:")
|
| 13 |
+
for row in rows:
|
| 14 |
+
print(f"ID: {row[0]}, Email: {row[1]}, API Key: {row[2]}, Created At: {row[3]}")
|
| 15 |
+
|
| 16 |
+
except sqlite3.Error as e:
|
| 17 |
+
print(f"An error occurred: {e}")
|
| 18 |
+
finally:
|
| 19 |
+
# Always ensure the connection is closed
|
| 20 |
+
conn.close()
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
d = print_db_data('db/accounts.db')
|
worker/db_worker/get_links.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
|
| 3 |
+
conn = sqlite3.connect('db/channel_links.db')
|
| 4 |
+
|
| 5 |
+
cursor = conn.execute('SELECT * FROM videos')
|
| 6 |
+
for row in cursor:
|
| 7 |
+
print(row)
|
| 8 |
+
conn.close()
|
worker/hf_uploader.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import argparse
|
| 4 |
+
from huggingface_hub import HfApi, login
|
| 5 |
+
|
| 6 |
+
def upload_to_hf(local_path, repo_id, repo_type="dataset", path_in_repo=None, token=None):
|
| 7 |
+
if token:
|
| 8 |
+
login(token=token)
|
| 9 |
+
elif not os.environ.get("HF_TOKEN"):
|
| 10 |
+
print("Warning: No HF_TOKEN found. Upload might fail if the repo is private or requires authentication.")
|
| 11 |
+
|
| 12 |
+
api = HfApi()
|
| 13 |
+
|
| 14 |
+
if not os.path.exists(local_path):
|
| 15 |
+
print(f"Error: Path does not exist: {local_path}", file=sys.stderr)
|
| 16 |
+
sys.exit(1)
|
| 17 |
+
|
| 18 |
+
# Ensure repo exists
|
| 19 |
+
try:
|
| 20 |
+
api.create_repo(repo_id=repo_id, repo_type=repo_type, exist_ok=True)
|
| 21 |
+
print(f"Verified repository: {repo_type}s/{repo_id}")
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print(f"Note: Could not verify or create repo (it might already exist or there's a permission issue). Continuing... ({e})")
|
| 24 |
+
|
| 25 |
+
if os.path.isfile(local_path):
|
| 26 |
+
if not path_in_repo:
|
| 27 |
+
path_in_repo = os.path.basename(local_path)
|
| 28 |
+
print(f"Uploading file '{local_path}' to 'hf://{repo_type}s/{repo_id}/{path_in_repo}'...")
|
| 29 |
+
try:
|
| 30 |
+
api.upload_file(
|
| 31 |
+
path_or_fileobj=local_path,
|
| 32 |
+
path_in_repo=path_in_repo,
|
| 33 |
+
repo_id=repo_id,
|
| 34 |
+
repo_type=repo_type
|
| 35 |
+
)
|
| 36 |
+
print("✅ File upload complete!")
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"❌ Failed to upload file: {e}", file=sys.stderr)
|
| 39 |
+
|
| 40 |
+
elif os.path.isdir(local_path):
|
| 41 |
+
if path_in_repo is None:
|
| 42 |
+
path_in_repo = "" # Root of repo by default
|
| 43 |
+
print(f"Syncing folder '{local_path}' to 'hf://{repo_type}s/{repo_id}/{path_in_repo}'...")
|
| 44 |
+
try:
|
| 45 |
+
api.upload_folder(
|
| 46 |
+
folder_path=local_path,
|
| 47 |
+
path_in_repo=path_in_repo,
|
| 48 |
+
repo_id=repo_id,
|
| 49 |
+
repo_type=repo_type
|
| 50 |
+
)
|
| 51 |
+
print("✅ Folder sync complete!")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"❌ Failed to sync folder: {e}", file=sys.stderr)
|
| 54 |
+
|
| 55 |
+
def main():
|
| 56 |
+
parser = argparse.ArgumentParser(description="Upload any video, file, or folder to Hugging Face")
|
| 57 |
+
parser.add_argument("input_path", type=str, help="Path to the local video file or directory (e.g. ./data or video.mp4)")
|
| 58 |
+
parser.add_argument("--repo", type=str, default="AdhyanshVerma/YT", help="Hugging Face repo ID (default: AdhyanshVerma/YT)")
|
| 59 |
+
parser.add_argument("--repo-type", type=str, default="dataset", choices=["dataset", "model", "space"], help="Type of HF repo (default: dataset)")
|
| 60 |
+
parser.add_argument("--path-in-repo", type=str, default=None, help="Destination path in the repo (defaults to filename or root of repo)")
|
| 61 |
+
parser.add_argument("--token", type=str, default=os.environ.get("HF_TOKEN"), help="HF Access Token (or set HF_TOKEN env var)")
|
| 62 |
+
|
| 63 |
+
args = parser.parse_args()
|
| 64 |
+
|
| 65 |
+
upload_to_hf(
|
| 66 |
+
local_path=args.input_path,
|
| 67 |
+
repo_id=args.repo,
|
| 68 |
+
repo_type=args.repo_type,
|
| 69 |
+
path_in_repo=args.path_in_repo,
|
| 70 |
+
token=args.token
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
main()
|
worker/models_worker/call_model.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import base64
|
| 4 |
+
import argparse
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from openai import OpenAI
|
| 7 |
+
|
| 8 |
+
def encode_image(image_path: str) -> str:
|
| 9 |
+
"""Read a local image file and return its base64 data URI."""
|
| 10 |
+
path = Path(image_path)
|
| 11 |
+
if not path.is_file():
|
| 12 |
+
raise FileNotFoundError(f"Image file not found: {image_path}")
|
| 13 |
+
|
| 14 |
+
# Determine MIME type from file extension
|
| 15 |
+
suffix = path.suffix.lower()
|
| 16 |
+
mime_type = {
|
| 17 |
+
".jpg": "image/jpeg",
|
| 18 |
+
".jpeg": "image/jpeg",
|
| 19 |
+
".png": "image/png",
|
| 20 |
+
".gif": "image/gif",
|
| 21 |
+
".webp": "image/webp",
|
| 22 |
+
}.get(suffix, "application/octet-stream")
|
| 23 |
+
|
| 24 |
+
with open(path, "rb") as f:
|
| 25 |
+
image_data = base64.b64encode(f.read()).decode("utf-8")
|
| 26 |
+
return f"data:{mime_type};base64,{image_data}"
|
| 27 |
+
|
| 28 |
+
def describe_image(
|
| 29 |
+
image_source: str,
|
| 30 |
+
model: str = "MiniMaxAI/MiniMax-M3",
|
| 31 |
+
api_key: str = None,
|
| 32 |
+
base_url: str = "https://api.featherless.ai/v1",
|
| 33 |
+
) -> str:
|
| 34 |
+
"""
|
| 35 |
+
Send an image (URL or local path) to the Featherless AI vision API
|
| 36 |
+
and return the generated description.
|
| 37 |
+
"""
|
| 38 |
+
# Use provided API key or fall back to environment variable
|
| 39 |
+
api_key = api_key or os.getenv("FEATHERLESS_API_KEY")
|
| 40 |
+
if not api_key:
|
| 41 |
+
raise ValueError("API key must be provided or set in FEATHERLESS_API_KEY env var")
|
| 42 |
+
|
| 43 |
+
client = OpenAI(base_url=base_url, api_key=api_key)
|
| 44 |
+
|
| 45 |
+
# Determine if image_source is a URL or a local file
|
| 46 |
+
if image_source.startswith(("http://", "https://")):
|
| 47 |
+
image_url = image_source
|
| 48 |
+
else:
|
| 49 |
+
# Assume it's a local file; encode to data URI
|
| 50 |
+
image_url = encode_image(image_source)
|
| 51 |
+
|
| 52 |
+
messages = [
|
| 53 |
+
{
|
| 54 |
+
"role": "user",
|
| 55 |
+
"content": [
|
| 56 |
+
{"type": "text", "text": "Describe the image in one paragraph."},
|
| 57 |
+
{"type": "image_url", "image_url": {"url": image_url}},
|
| 58 |
+
],
|
| 59 |
+
}
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
response = client.chat.completions.create(
|
| 64 |
+
model=model,
|
| 65 |
+
messages=messages,
|
| 66 |
+
# You can add more parameters, e.g., temperature, max_tokens
|
| 67 |
+
)
|
| 68 |
+
return response.choices[0].message.content
|
| 69 |
+
except Exception as e:
|
| 70 |
+
# Catch and re-raise with a user-friendly message
|
| 71 |
+
raise RuntimeError(f"API call failed: {e}") from e
|
| 72 |
+
|
| 73 |
+
def main():
|
| 74 |
+
parser = argparse.ArgumentParser(
|
| 75 |
+
description="Describe an image using Featherless AI vision models."
|
| 76 |
+
)
|
| 77 |
+
parser.add_argument(
|
| 78 |
+
"image",
|
| 79 |
+
help="Image URL or local file path.",
|
| 80 |
+
)
|
| 81 |
+
parser.add_argument(
|
| 82 |
+
"--model",
|
| 83 |
+
default="MiniMaxAI/MiniMax-M3",
|
| 84 |
+
help="Model name (default: MiniMaxAI/MiniMax-M3).",
|
| 85 |
+
)
|
| 86 |
+
parser.add_argument(
|
| 87 |
+
"--api-key",
|
| 88 |
+
help="Featherless API key (overrides FEATHERLESS_API_KEY env var).",
|
| 89 |
+
)
|
| 90 |
+
parser.add_argument(
|
| 91 |
+
"--base-url",
|
| 92 |
+
default="https://api.featherless.ai/v1",
|
| 93 |
+
help="Base URL for the API (default: https://api.featherless.ai/v1).",
|
| 94 |
+
)
|
| 95 |
+
args = parser.parse_args()
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
description = describe_image(
|
| 99 |
+
image_source=args.image,
|
| 100 |
+
model=args.model,
|
| 101 |
+
api_key=args.api_key,
|
| 102 |
+
base_url=args.base_url,
|
| 103 |
+
)
|
| 104 |
+
print(description)
|
| 105 |
+
except Exception as e:
|
| 106 |
+
print(f"Error: {e}", file=sys.stderr)
|
| 107 |
+
sys.exit(1)
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
main()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
"""
|
| 116 |
+
Bro, sif you are here then please add ahere video support and make this as a function as a whole!
|
| 117 |
+
"""
|
worker/models_worker/video_worker.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import base64
|
| 3 |
+
import os
|
| 4 |
+
import argparse
|
| 5 |
+
import sys
|
| 6 |
+
from openai import OpenAI
|
| 7 |
+
|
| 8 |
+
def extract_frames(video_path, num_frames=5):
|
| 9 |
+
"""Extract a few evenly spaced frames from a video and encode to base64."""
|
| 10 |
+
cap = cv2.VideoCapture(video_path)
|
| 11 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 12 |
+
frames = []
|
| 13 |
+
|
| 14 |
+
if total_frames == 0:
|
| 15 |
+
return frames
|
| 16 |
+
|
| 17 |
+
step = max(1, total_frames // num_frames)
|
| 18 |
+
|
| 19 |
+
for i in range(num_frames):
|
| 20 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, min(i * step, total_frames - 1))
|
| 21 |
+
ret, frame = cap.read()
|
| 22 |
+
if ret:
|
| 23 |
+
# encode to jpeg
|
| 24 |
+
_, buffer = cv2.imencode('.jpg', frame)
|
| 25 |
+
b64_img = base64.b64encode(buffer).decode('utf-8')
|
| 26 |
+
frames.append(f"data:image/jpeg;base64,{b64_img}")
|
| 27 |
+
|
| 28 |
+
cap.release()
|
| 29 |
+
return frames
|
| 30 |
+
|
| 31 |
+
def analyze_video(video_source, prompt, api_key=None, model="MiniMaxAI/MiniMax-M3", base_url="https://api.featherless.ai/v1"):
|
| 32 |
+
api_key = api_key or os.getenv("FEATHERLESS_API_KEY")
|
| 33 |
+
if not api_key:
|
| 34 |
+
raise ValueError("API key must be provided or set in FEATHERLESS_API_KEY env var")
|
| 35 |
+
|
| 36 |
+
client = OpenAI(base_url=base_url, api_key=api_key)
|
| 37 |
+
|
| 38 |
+
print(f"[+] Extracting frames from {video_source}...")
|
| 39 |
+
frames = extract_frames(video_source, num_frames=5)
|
| 40 |
+
|
| 41 |
+
if not frames:
|
| 42 |
+
raise RuntimeError("Failed to extract any frames from the video.")
|
| 43 |
+
|
| 44 |
+
content = [{"type": "text", "text": prompt}]
|
| 45 |
+
for frame in frames:
|
| 46 |
+
content.append({"type": "image_url", "image_url": {"url": frame}})
|
| 47 |
+
|
| 48 |
+
messages = [
|
| 49 |
+
{
|
| 50 |
+
"role": "user",
|
| 51 |
+
"content": content
|
| 52 |
+
}
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
print(f"[+] Sending {len(frames)} frames to vision model...")
|
| 56 |
+
response = client.chat.completions.create(
|
| 57 |
+
model=model,
|
| 58 |
+
messages=messages
|
| 59 |
+
)
|
| 60 |
+
return response.choices[0].message.content
|
| 61 |
+
|
| 62 |
+
def main():
|
| 63 |
+
parser = argparse.ArgumentParser(description="Analyze video using Featherless AI vision models.")
|
| 64 |
+
parser.add_argument("--video-url", required=True, help="Video file path or URL.")
|
| 65 |
+
parser.add_argument("--prompt", required=True, help="Prompt for analysis.")
|
| 66 |
+
parser.add_argument("--api-key", help="Featherless API key (overrides FEATHERLESS_API_KEY env var).")
|
| 67 |
+
parser.add_argument("--model", default="MiniMaxAI/MiniMax-M3", help="Model name.")
|
| 68 |
+
parser.add_argument("--base-url", default="https://api.featherless.ai/v1", help="Base API URL.")
|
| 69 |
+
args = parser.parse_args()
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
description = analyze_video(
|
| 73 |
+
video_source=args.video_url,
|
| 74 |
+
prompt=args.prompt,
|
| 75 |
+
api_key=args.api_key,
|
| 76 |
+
model=args.model,
|
| 77 |
+
base_url=args.base_url,
|
| 78 |
+
)
|
| 79 |
+
print("\n--- Video Analysis ---\n")
|
| 80 |
+
print(description)
|
| 81 |
+
print("\n----------------------\n")
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f"Error: {e}", file=sys.stderr)
|
| 84 |
+
sys.exit(1)
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
main()
|
worker/youtube_worker/audio_downloader.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import yt_dlp
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
| 6 |
+
|
| 7 |
+
def download_audio(url, output_path='./', cookies_from_browser=None, audio_format='mp3'):
|
| 8 |
+
"""
|
| 9 |
+
Downloads the best audio from a given URL and converts it to the specified format.
|
| 10 |
+
|
| 11 |
+
Args:
|
| 12 |
+
url (str): The URL of the video to extract audio from.
|
| 13 |
+
output_path (str): The directory to save the audio.
|
| 14 |
+
cookies_from_browser (str): Browser name to extract cookies from (e.g. 'chrome').
|
| 15 |
+
audio_format (str): Desired audio format (mp3, wav, m4a). Default is 'mp3'.
|
| 16 |
+
"""
|
| 17 |
+
ydl_opts = {
|
| 18 |
+
'format': 'bestaudio/best',
|
| 19 |
+
'outtmpl': os.path.join(output_path, '%(title)s.%(ext)s'),
|
| 20 |
+
'postprocessors': [{
|
| 21 |
+
'key': 'FFmpegExtractAudio',
|
| 22 |
+
'preferredcodec': audio_format,
|
| 23 |
+
'preferredquality': '192',
|
| 24 |
+
}],
|
| 25 |
+
'quiet': False,
|
| 26 |
+
'no_warnings': False,
|
| 27 |
+
'ignoreerrors': True,
|
| 28 |
+
'geo_bypass': True,
|
| 29 |
+
'sleep_interval_requests': 1,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if cookies_from_browser:
|
| 33 |
+
ydl_opts['cookiesfrombrowser'] = (cookies_from_browser, )
|
| 34 |
+
elif os.path.exists('cookies.txt'):
|
| 35 |
+
ydl_opts['cookiefile'] = 'cookies.txt'
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
logging.info(f"Starting audio download for {url}")
|
| 39 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 40 |
+
error_code = ydl.download([url])
|
| 41 |
+
if error_code == 0:
|
| 42 |
+
logging.info(f"Successfully downloaded audio to {output_path}")
|
| 43 |
+
return True
|
| 44 |
+
else:
|
| 45 |
+
logging.error(f"yt-dlp encountered an error (code {error_code}).")
|
| 46 |
+
return False
|
| 47 |
+
except yt_dlp.utils.DownloadError as e:
|
| 48 |
+
logging.error(f"Download error: {e}")
|
| 49 |
+
return False
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logging.error(f"Unexpected error: {e}")
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
import argparse
|
| 56 |
+
parser = argparse.ArgumentParser(description="Download audio using yt-dlp.")
|
| 57 |
+
parser.add_argument("url", help="URL of the video")
|
| 58 |
+
parser.add_argument("--output", "-o", default="./", help="Output directory")
|
| 59 |
+
parser.add_argument("--format", "-f", choices=["mp3", "wav", "m4a"], default="mp3", help="Audio format")
|
| 60 |
+
parser.add_argument("--cookies", "-c", help="Browser to extract cookies from (e.g. chrome, firefox)")
|
| 61 |
+
|
| 62 |
+
args = parser.parse_args()
|
| 63 |
+
|
| 64 |
+
if not os.path.exists(args.output):
|
| 65 |
+
os.makedirs(args.output, exist_ok=True)
|
| 66 |
+
|
| 67 |
+
download_audio(args.url, args.output, args.cookies, args.format)
|
worker/youtube_worker/audio_worker/audio_splitter.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
import argparse
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
| 8 |
+
|
| 9 |
+
def get_audio_duration(input_path):
|
| 10 |
+
try:
|
| 11 |
+
result = subprocess.run(
|
| 12 |
+
["ffprobe", "-v", "error", "-show_entries",
|
| 13 |
+
"format=duration", "-of",
|
| 14 |
+
"default=noprint_wrappers=1:nokey=1", input_path],
|
| 15 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True
|
| 16 |
+
)
|
| 17 |
+
return float(result.stdout.strip())
|
| 18 |
+
except:
|
| 19 |
+
return None
|
| 20 |
+
|
| 21 |
+
def detect_silences(input_path, noise_level="-20dB", duration=0.3):
|
| 22 |
+
"""
|
| 23 |
+
Runs ffmpeg silencedetect to find natural pauses in the audio.
|
| 24 |
+
Returns a list of timestamps (middle of the silence).
|
| 25 |
+
"""
|
| 26 |
+
logging.info("Analyzing audio to find silences (avoiding mid-sentence cuts)...")
|
| 27 |
+
command = [
|
| 28 |
+
"ffmpeg", "-i", input_path,
|
| 29 |
+
"-af", f"silencedetect=noise={noise_level}:d={duration}",
|
| 30 |
+
"-f", "null", "-"
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
| 34 |
+
if result.returncode != 0:
|
| 35 |
+
logging.error(f"Silence detection failed: {result.stderr}")
|
| 36 |
+
return []
|
| 37 |
+
output = result.stderr
|
| 38 |
+
|
| 39 |
+
silence_starts = []
|
| 40 |
+
silence_ends = []
|
| 41 |
+
for line in output.split('\n'):
|
| 42 |
+
if "silence_start" in line:
|
| 43 |
+
match = re.search(r"silence_start:\s+([\d\.]+)", line)
|
| 44 |
+
if match:
|
| 45 |
+
silence_starts.append(float(match.group(1)))
|
| 46 |
+
elif "silence_end" in line:
|
| 47 |
+
match = re.search(r"silence_end:\s+([\d\.]+)", line)
|
| 48 |
+
if match:
|
| 49 |
+
silence_ends.append(float(match.group(1)))
|
| 50 |
+
|
| 51 |
+
silence_points = []
|
| 52 |
+
for s, e in zip(silence_starts, silence_ends):
|
| 53 |
+
# Calculate the midpoint of the silence to use as the cut point
|
| 54 |
+
silence_points.append((s + e) / 2.0)
|
| 55 |
+
|
| 56 |
+
return silence_points
|
| 57 |
+
|
| 58 |
+
def calculate_smart_audio_splits(total_duration, silence_points, target_chunk=60, min_tail=15):
|
| 59 |
+
"""
|
| 60 |
+
Finds split points close to the target chunk size, prioritizing silences
|
| 61 |
+
and avoiding tiny fragments at the end.
|
| 62 |
+
"""
|
| 63 |
+
split_points = []
|
| 64 |
+
current_target = target_chunk
|
| 65 |
+
|
| 66 |
+
while current_target < total_duration:
|
| 67 |
+
if (total_duration - current_target) < min_tail:
|
| 68 |
+
logging.info(f"Remaining {total_duration - current_target:.1f}s is too short, merging with the last clip.")
|
| 69 |
+
break
|
| 70 |
+
|
| 71 |
+
# Search for silences within +/- 15 seconds of our target time
|
| 72 |
+
valid_silences = [p for p in silence_points if abs(p - current_target) <= 15]
|
| 73 |
+
|
| 74 |
+
if not valid_silences and silence_points:
|
| 75 |
+
last_split = split_points[-1] if split_points else 0
|
| 76 |
+
valid_silences = [p for p in silence_points if p > last_split + 5]
|
| 77 |
+
|
| 78 |
+
if valid_silences:
|
| 79 |
+
# Pick the silence closest to the target
|
| 80 |
+
best_point = min(valid_silences, key=lambda p: abs(p - current_target))
|
| 81 |
+
split_points.append(best_point)
|
| 82 |
+
logging.info(f"Found natural silence at {best_point:.1f}s")
|
| 83 |
+
current_target = best_point + target_chunk
|
| 84 |
+
else:
|
| 85 |
+
# Hard cut if no silence is found near the target
|
| 86 |
+
logging.warning(f"No silence found near {current_target}s, falling back to exact cut.")
|
| 87 |
+
split_points.append(current_target)
|
| 88 |
+
current_target += target_chunk
|
| 89 |
+
|
| 90 |
+
return split_points
|
| 91 |
+
|
| 92 |
+
def split_audio(input_path, chunk_duration=60, output_dir=None):
|
| 93 |
+
if not os.path.exists(input_path):
|
| 94 |
+
logging.error(f"Input audio '{input_path}' not found.")
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
input_path = os.path.abspath(input_path)
|
| 98 |
+
filename = os.path.basename(input_path)
|
| 99 |
+
name, ext = os.path.splitext(filename)
|
| 100 |
+
name = name.rstrip('.')
|
| 101 |
+
|
| 102 |
+
if output_dir is None:
|
| 103 |
+
output_dir = os.path.join(os.path.dirname(input_path), f"{name}_chunks")
|
| 104 |
+
|
| 105 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 106 |
+
output_pattern = os.path.join(output_dir, f"{name}_chunk_%03d{ext}")
|
| 107 |
+
|
| 108 |
+
total_duration = get_audio_duration(input_path)
|
| 109 |
+
if not total_duration:
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
logging.info(f"Total audio duration: {total_duration:.1f}s")
|
| 113 |
+
|
| 114 |
+
silences = detect_silences(input_path)
|
| 115 |
+
|
| 116 |
+
min_tail = max(15, chunk_duration // 4)
|
| 117 |
+
split_points = calculate_smart_audio_splits(total_duration, silences, target_chunk=chunk_duration, min_tail=min_tail)
|
| 118 |
+
|
| 119 |
+
command = [
|
| 120 |
+
"ffmpeg",
|
| 121 |
+
"-i", input_path,
|
| 122 |
+
"-c", "copy",
|
| 123 |
+
"-map", "0",
|
| 124 |
+
"-f", "segment",
|
| 125 |
+
"-reset_timestamps", "1"
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
if split_points:
|
| 129 |
+
times_str = ",".join(str(round(p, 3)) for p in split_points)
|
| 130 |
+
command.extend(["-segment_times", times_str])
|
| 131 |
+
else:
|
| 132 |
+
command.extend(["-segment_time", str(total_duration + 10)])
|
| 133 |
+
|
| 134 |
+
command.append(output_pattern)
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
logging.info("Executing split...")
|
| 138 |
+
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
| 139 |
+
logging.info("Successfully split the audio!")
|
| 140 |
+
return True
|
| 141 |
+
except subprocess.CalledProcessError as e:
|
| 142 |
+
logging.error("Error during audio splitting:")
|
| 143 |
+
logging.error(e.stderr.decode('utf-8', errors='replace'))
|
| 144 |
+
return False
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
parser = argparse.ArgumentParser(description="Smart split audio into chunks.")
|
| 148 |
+
parser.add_argument("audio_path", help="Path to the audio file")
|
| 149 |
+
parser.add_argument("--duration", "-d", type=int, default=60, help="Target duration (default: 60)")
|
| 150 |
+
parser.add_argument("--output", "-o", help="Output directory")
|
| 151 |
+
args = parser.parse_args()
|
| 152 |
+
|
| 153 |
+
split_audio(args.audio_path, chunk_duration=args.duration, output_dir=args.output)
|
worker/youtube_worker/downloader.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import yt_dlp
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
| 6 |
+
|
| 7 |
+
def download_video(url, resolution='HD', output_path='./', cookies_from_browser=None):
|
| 8 |
+
"""
|
| 9 |
+
Download a video from a given URL using yt-dlp at a specified resolution.
|
| 10 |
+
|
| 11 |
+
Args:
|
| 12 |
+
url (str): The URL of the video to download.
|
| 13 |
+
resolution (str): The desired resolution. Options: '4K', '2K', 'HD'. Default is 'HD'.
|
| 14 |
+
output_path (str): The directory to save the video. Default is current directory.
|
| 15 |
+
cookies_from_browser (str): Browser to extract cookies from (e.g., 'chrome', 'firefox', 'edge').
|
| 16 |
+
Use this if you encounter blocking or age-restricted content.
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
bool: True if successful, False otherwise.
|
| 20 |
+
"""
|
| 21 |
+
# Map resolutions to their approximate vertical pixel height
|
| 22 |
+
resolution_map = {
|
| 23 |
+
'4K': '2160',
|
| 24 |
+
'2K': '1440',
|
| 25 |
+
'HD': '1080'
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
height = resolution_map.get(resolution.upper(), '1080')
|
| 29 |
+
|
| 30 |
+
# We ask for the best video with height <= the specified height,
|
| 31 |
+
# plus the best audio, and merge them. If that's not available,
|
| 32 |
+
# it falls back to the best single file with height <= specified height.
|
| 33 |
+
format_string = f'bestvideo[height<={height}]+bestaudio/best[height<={height}]'
|
| 34 |
+
|
| 35 |
+
ydl_opts = {
|
| 36 |
+
'format': format_string,
|
| 37 |
+
'outtmpl': os.path.join(output_path, '%(title)s.%(ext)s'),
|
| 38 |
+
'merge_output_format': 'mp4', # standardizing on mp4 where possible
|
| 39 |
+
'quiet': False,
|
| 40 |
+
'no_warnings': False,
|
| 41 |
+
'nocheckcertificate': True,
|
| 42 |
+
'ignoreerrors': True, # skip unavailable videos in playlists
|
| 43 |
+
'geo_bypass': True, # try to bypass geographic restrictions
|
| 44 |
+
# Adds sleep to reduce likelihood of getting rate-limited or banned
|
| 45 |
+
'sleep_interval_requests': 1,
|
| 46 |
+
'sleep_interval': 1,
|
| 47 |
+
'max_sleep_interval': 3,
|
| 48 |
+
# Fake user agent can help avoid bot detection
|
| 49 |
+
'http_headers': {
|
| 50 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if cookies_from_browser:
|
| 55 |
+
ydl_opts['cookiesfrombrowser'] = (cookies_from_browser, )
|
| 56 |
+
elif os.path.exists('cookies.txt'):
|
| 57 |
+
ydl_opts['cookiefile'] = 'cookies.txt'
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
logging.info(f"Starting download for {url} at max resolution {resolution} ({height}p)")
|
| 61 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 62 |
+
info = ydl.extract_info(url, download=True)
|
| 63 |
+
if info:
|
| 64 |
+
filename = None
|
| 65 |
+
if 'requested_downloads' in info:
|
| 66 |
+
filename = info['requested_downloads'][0]['filepath']
|
| 67 |
+
else:
|
| 68 |
+
filename = ydl.prepare_filename(info)
|
| 69 |
+
if ydl_opts.get('merge_output_format'):
|
| 70 |
+
filename = os.path.splitext(filename)[0] + '.' + ydl_opts['merge_output_format']
|
| 71 |
+
|
| 72 |
+
logging.info(f"Successfully downloaded to {filename}")
|
| 73 |
+
print(os.path.abspath(filename))
|
| 74 |
+
return True
|
| 75 |
+
else:
|
| 76 |
+
logging.error(f"yt-dlp encountered an error.")
|
| 77 |
+
return False
|
| 78 |
+
except yt_dlp.utils.DownloadError as e:
|
| 79 |
+
logging.error(f"Download error: {e}")
|
| 80 |
+
return False
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logging.error(f"Unexpected error: {e}")
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
import argparse
|
| 87 |
+
parser = argparse.ArgumentParser(description="Download videos using yt-dlp.")
|
| 88 |
+
parser.add_argument("url", help="URL of the video to download")
|
| 89 |
+
parser.add_argument("--resolution", "-r", choices=["4K", "2K", "HD"], default="HD", help="Target resolution (4K, 2K, HD). Default: HD")
|
| 90 |
+
parser.add_argument("--output", "-o", default="./", help="Output directory path. Default: current directory")
|
| 91 |
+
parser.add_argument("--cookies", "-c", help="Browser to extract cookies from (e.g. chrome, firefox, edge) to prevent being blocked.")
|
| 92 |
+
|
| 93 |
+
args = parser.parse_args()
|
| 94 |
+
|
| 95 |
+
if not os.path.exists(args.output):
|
| 96 |
+
os.makedirs(args.output, exist_ok=True)
|
| 97 |
+
|
| 98 |
+
download_video(args.url, args.resolution, args.output, args.cookies)
|
worker/youtube_worker/video_worker/splitter.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
import argparse
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
| 7 |
+
|
| 8 |
+
def get_video_duration(input_path):
|
| 9 |
+
try:
|
| 10 |
+
result = subprocess.run(
|
| 11 |
+
["ffprobe", "-v", "error", "-show_entries",
|
| 12 |
+
"format=duration", "-of",
|
| 13 |
+
"default=noprint_wrappers=1:nokey=1", input_path],
|
| 14 |
+
stdout=subprocess.PIPE,
|
| 15 |
+
stderr=subprocess.PIPE,
|
| 16 |
+
text=True,
|
| 17 |
+
check=True
|
| 18 |
+
)
|
| 19 |
+
return float(result.stdout.strip())
|
| 20 |
+
except Exception as e:
|
| 21 |
+
logging.error(f"Failed to get duration: {e}")
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
def calculate_split_points(total_duration, chunk_duration=60, min_tail_duration=15):
|
| 25 |
+
"""
|
| 26 |
+
Calculator system that prevents tiny clips at the end by merging them.
|
| 27 |
+
If the remaining clip is less than min_tail_duration, it won't split,
|
| 28 |
+
merging it into the previous clip (making it slightly longer).
|
| 29 |
+
"""
|
| 30 |
+
split_points = []
|
| 31 |
+
current_time = chunk_duration
|
| 32 |
+
while current_time < total_duration:
|
| 33 |
+
# Check if the remaining part is too small
|
| 34 |
+
if (total_duration - current_time) < min_tail_duration:
|
| 35 |
+
logging.info(f"Remaining {total_duration - current_time:.1f}s is too short, merging with the last clip.")
|
| 36 |
+
break
|
| 37 |
+
split_points.append(current_time)
|
| 38 |
+
current_time += chunk_duration
|
| 39 |
+
return split_points
|
| 40 |
+
|
| 41 |
+
def split_video(input_path, chunk_duration=60, output_dir=None):
|
| 42 |
+
"""
|
| 43 |
+
Splits a video into smaller chunks of a specified duration using ffmpeg,
|
| 44 |
+
with smart distribution logic.
|
| 45 |
+
"""
|
| 46 |
+
if not os.path.exists(input_path):
|
| 47 |
+
logging.error(f"Input video '{input_path}' not found.")
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
input_path = os.path.abspath(input_path)
|
| 51 |
+
filename = os.path.basename(input_path)
|
| 52 |
+
name, ext = os.path.splitext(filename)
|
| 53 |
+
name = name.rstrip('.')
|
| 54 |
+
|
| 55 |
+
if output_dir is None:
|
| 56 |
+
output_dir = os.path.join(os.path.dirname(input_path), f"{name}_chunks")
|
| 57 |
+
|
| 58 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 59 |
+
output_pattern = os.path.join(output_dir, f"{name}_chunk_%03d{ext}")
|
| 60 |
+
|
| 61 |
+
total_duration = get_video_duration(input_path)
|
| 62 |
+
if total_duration is None:
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
logging.info(f"Total video duration: {total_duration:.1f}s")
|
| 66 |
+
|
| 67 |
+
# Calculate the smart split points
|
| 68 |
+
# Min tail is a quarter of chunk size or at least 15s
|
| 69 |
+
min_tail = max(15, chunk_duration // 4)
|
| 70 |
+
split_points = calculate_split_points(total_duration, chunk_duration, min_tail_duration=min_tail)
|
| 71 |
+
|
| 72 |
+
logging.info(f"Calculated split points: {[round(p, 1) for p in split_points]}")
|
| 73 |
+
|
| 74 |
+
command = [
|
| 75 |
+
"ffmpeg",
|
| 76 |
+
"-i", input_path,
|
| 77 |
+
"-map", "0",
|
| 78 |
+
"-f", "segment",
|
| 79 |
+
"-reset_timestamps", "1"
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
if split_points:
|
| 83 |
+
times_str = ",".join(str(round(p, 3)) for p in split_points)
|
| 84 |
+
command.extend(["-segment_times", times_str])
|
| 85 |
+
else:
|
| 86 |
+
# If no split points (e.g. video is too short), just use segment_time
|
| 87 |
+
# effectively keeping it whole or just standard copy
|
| 88 |
+
command.extend(["-segment_time", str(total_duration + 10)])
|
| 89 |
+
|
| 90 |
+
command.append(output_pattern)
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
| 94 |
+
logging.info("Successfully split the video!")
|
| 95 |
+
return True
|
| 96 |
+
except subprocess.CalledProcessError as e:
|
| 97 |
+
logging.error("Error during video splitting:")
|
| 98 |
+
logging.error(e.stderr.decode('utf-8', errors='replace'))
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
parser = argparse.ArgumentParser(description="Smart split video into chunks.")
|
| 103 |
+
parser.add_argument("video_path", help="Path to the video file")
|
| 104 |
+
parser.add_argument("--duration", "-d", type=int, default=60, help="Target duration (default: 60)")
|
| 105 |
+
parser.add_argument("--output", "-o", help="Output directory")
|
| 106 |
+
args = parser.parse_args()
|
| 107 |
+
|
| 108 |
+
split_video(args.video_path, chunk_duration=args.duration, output_dir=args.output)
|