AdhyanshVerma commited on
Commit
4eac606
·
verified ·
1 Parent(s): 9e2b20e

Upload 15 files

Browse files
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", "main.py"]
 
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)