AdhyanshVerma commited on
Commit
7d44fe7
·
verified ·
1 Parent(s): f3e527d

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +90 -582
main.py CHANGED
@@ -1,588 +1,96 @@
1
- """
2
- FastAPI — DrivePurge Release Automation
3
- ========================================
4
- HF Spaces ready (port 7860).
5
- Endpoints
6
- ---------
7
- GET /health
8
- POST /update-file
9
- GET /releases
10
- GET /releases/{tag}
11
- GET /links ← ALL release download links, every OS, flat + grouped
12
- GET /links/latest ← latest release only, same format
13
- GET /links/{tag} ← one specific tag, same format
14
- POST /process-release/{tag}
15
- POST /trigger ← full pipeline (6 steps — see docstring)
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import base64
21
- import json
22
  import os
23
- import re
24
- from datetime import datetime, timezone
25
- from pathlib import Path
26
- from typing import Optional
27
-
28
- import httpx
29
- from fastapi import FastAPI, HTTPException
30
- from fastapi.middleware.cors import CORSMiddleware
31
- from pydantic import BaseModel, Field
32
-
33
- # ── Config ────────────────────────────────────────────────────────────────────
34
- GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "")
35
-
36
- # Repo that holds the app + releases + version.json
37
- REPO_OWNER = os.getenv("REPO_OWNER", "Drive-Purge")
38
- REPO_NAME = os.getenv("REPO_NAME", "DrivePurge_")
39
-
40
- # Repo that holds the website (download.html lives here)
41
- WEBSITE_OWNER = os.getenv("WEBSITE_OWNER", "Drive-Purge")
42
- WEBSITE_REPO = os.getenv("WEBSITE_REPO", "drive-purge.github.io")
43
-
44
- OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/tmp/releases")) # /tmp is writable on HF Spaces
45
-
46
- BASE_URL = "https://api.github.com"
47
-
48
- def _gh_headers() -> dict:
49
- h = {
50
- "Accept": "application/vnd.github+json",
51
- "X-GitHub-Api-Version": "2022-11-28",
52
- }
53
- if GITHUB_TOKEN:
54
- h["Authorization"] = f"Bearer {GITHUB_TOKEN}"
55
- return h
56
-
57
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
58
-
59
- # ── OS / arch detection ───────────────────────────────────────────────────────
60
- _OS_RULES: list[tuple[str, str, str]] = [
61
- (r"windows.*amd64|win.*x64|win.*amd64", "windows", "x86_64"),
62
- (r"windows.*arm64|win.*arm64", "windows", "arm64"),
63
- (r"windows.*arm\b|win.*arm\b", "windows", "arm"),
64
- (r"\.exe$|windows|win\b", "windows", "x86_64"),
65
- (r"macos.*arm64|mac.*arm64|darwin.*arm64", "macos", "arm64"),
66
- (r"macos.*amd64|mac.*amd64|darwin.*amd64", "macos", "x86_64"),
67
- (r"macos.*arm\b|mac.*arm\b", "macos", "arm64"),
68
- (r"macos|darwin|osx", "macos", "universal"),
69
- (r"linux.*amd64|linux.*x86_64", "linux", "x86_64"),
70
- (r"linux.*arm64|linux.*aarch64", "linux", "arm64"),
71
- (r"linux.*arm\b", "linux", "arm"),
72
- (r"linux.*386\b|linux.*x86\b", "linux", "x86"),
73
- (r"linux", "linux", "x86_64"),
74
- (r"checksums?|sha256|md5|hash", "all", "all"),
75
- ]
76
-
77
- def _detect_platform(filename: str) -> tuple[str, str]:
78
- lower = filename.lower()
79
- for pattern, platform, arch in _OS_RULES:
80
- if re.search(pattern, lower):
81
- return platform, arch
82
- return "unknown", "unknown"
83
-
84
-
85
- # ── FastAPI app ───────────────────────────────────────────────────────────────
86
- app = FastAPI(
87
- title="DrivePurge Release Automation",
88
- description=(
89
- "Automates version tracking, release scanning, per-OS asset mirroring, "
90
- "metadata JSON generation, and **website download-link patching** for DrivePurge.\n\n"
91
- "Deploy on Hugging Face Spaces (Docker, port 7860) or run locally."
92
- ),
93
- version="1.0.0",
94
- docs_url="/docs",
95
- redoc_url="/redoc",
96
- )
97
-
98
- app.add_middleware(
99
- CORSMiddleware,
100
- allow_origins=["*"],
101
- allow_methods=["*"],
102
- allow_headers=["*"],
103
- )
104
-
105
- # ── Request models ────────────────────────────────────────────────────────────
106
- class UpdateFileRequest(BaseModel):
107
- path: str = Field(..., example="version.json")
108
- content: dict | list | str = Field(..., description="dict/list → JSON; str → raw")
109
- message: str = Field(..., example="chore: bump version")
110
- branch: str = Field("main")
111
-
112
- class TriggerRequest(BaseModel):
113
- version: str = Field(..., example="1.1")
114
- details: str = Field("", example="Bug fixes and performance improvements",
115
- description="Release notes written into version.json")
116
- download_assets: bool = Field(True, description="Also stream binaries to /tmp?")
117
- branch: str = Field("main")
118
-
119
- class ProcessReleaseRequest(BaseModel):
120
- download_assets: bool = Field(True)
121
-
122
- # ── Generic GitHub helpers ────────────────────────────────────────────────────
123
-
124
- async def gh_get(client: httpx.AsyncClient, path: str) -> dict | list:
125
- r = await client.get(f"{BASE_URL}{path}", headers=_gh_headers())
126
- if r.status_code == 404:
127
- raise HTTPException(404, f"Not found: {path}")
128
- if r.status_code not in (200, 201):
129
- raise HTTPException(r.status_code, f"GitHub API: {r.text[:400]}")
130
- return r.json()
131
-
132
- async def gh_put(client: httpx.AsyncClient, path: str, payload: dict) -> dict:
133
- r = await client.put(f"{BASE_URL}{path}", headers=_gh_headers(), json=payload)
134
- if r.status_code not in (200, 201):
135
- raise HTTPException(r.status_code, f"GitHub API: {r.text[:400]}")
136
- return r.json()
137
-
138
- def _b64_json(content: dict | list | str) -> str:
139
- """Encode content to base64 for the GitHub Contents API."""
140
- raw = json.dumps(content, indent=2) if isinstance(content, (dict, list)) else str(content)
141
- return base64.b64encode(raw.encode()).decode()
142
 
143
- def _b64_html(html: str) -> str:
144
- return base64.b64encode(html.encode("utf-8")).decode()
145
-
146
- async def _file_sha(
147
- client: httpx.AsyncClient,
148
- path: str,
149
- branch: str,
150
- owner: str = "",
151
- repo: str = "",
152
- ) -> Optional[str]:
153
- """Return the current SHA of a file in any repo. None if it doesn't exist."""
154
- o = owner or REPO_OWNER
155
- r = repo or REPO_NAME
156
  try:
157
- d = await gh_get(client, f"/repos/{o}/{r}/contents/{path}?ref={branch}")
158
- return d.get("sha") # type: ignore[union-attr]
159
- except HTTPException as e:
160
- if e.status_code == 404:
161
- return None
162
- raise
163
-
164
- def _slug(tag: str) -> str:
165
- return re.sub(r"[^a-zA-Z0-9_\-]", "_", tag.lstrip("v"))
166
-
167
- # ── Asset / links builders ────────────────────────────────────────────────────
168
-
169
- def _asset_entry(asset: dict, local_path: Optional[str] = None) -> dict:
170
- platform, arch = _detect_platform(asset["name"])
171
- return {
172
- "name": asset["name"],
173
- "os": platform,
174
- "arch": arch,
175
- "size_bytes": asset["size"],
176
- "download_url": asset["browser_download_url"],
177
- "content_type": asset["content_type"],
178
- "created_at": asset["created_at"],
179
- "local_path": local_path,
180
- }
181
-
182
- def _links_for_release(release: dict) -> dict:
183
- """
184
- Return clean download-links payload for one release.
185
- Shape
186
- -----
187
- {
188
- tag, name, published_at, html_url, total_assets,
189
- all: [ {name, os, arch, download_url, size_bytes, content_type, created_at} ],
190
- by_os: { "windows": {"x86_64": [...]}, "macos": {"arm64": [...]}, ... }
191
- }
192
- """
193
- flat: list[dict] = []
194
- by_os: dict[str, dict[str, list]] = {}
195
-
196
- for asset in release.get("assets", []):
197
- platform, arch = _detect_platform(asset["name"])
198
- entry = {
199
- "name": asset["name"],
200
- "os": platform,
201
- "arch": arch,
202
- "download_url": asset["browser_download_url"],
203
- "size_bytes": asset["size"],
204
- "content_type": asset["content_type"],
205
- "created_at": asset["created_at"],
206
- }
207
- flat.append(entry)
208
- by_os.setdefault(platform, {}).setdefault(arch, []).append(entry)
209
-
210
- return {
211
- "tag": release["tag_name"],
212
- "name": release["name"],
213
- "published_at": release.get("published_at"),
214
- "html_url": release["html_url"],
215
- "total_assets": len(flat),
216
- "all": flat,
217
- "by_os": by_os,
218
- }
219
-
220
- def _build_metadata(release: dict, downloaded: list[dict]) -> dict:
221
- dl_map = {d["name"]: d["path"] for d in downloaded}
222
- assets = [_asset_entry(a, dl_map.get(a["name"])) for a in release.get("assets", [])]
223
- by_os: dict[str, list] = {}
224
- for a in assets:
225
- by_os.setdefault(a["os"], []).append(a)
226
- return {
227
- "repo": f"{REPO_OWNER}/{REPO_NAME}",
228
- "tag": release["tag_name"],
229
- "name": release["name"],
230
- "body": release.get("body", ""),
231
- "published_at": release.get("published_at"),
232
- "html_url": release["html_url"],
233
- "assets": assets,
234
- "by_os": by_os,
235
- "generated_at": datetime.now(timezone.utc).isoformat(),
236
- }
237
-
238
- def _pick_zip_url(by_os: dict, os_name: str) -> Optional[str]:
239
- """Return the first .zip download URL for the given OS."""
240
- for _arch, assets in by_os.get(os_name, {}).items():
241
- for asset in assets:
242
- if asset["name"].lower().endswith(".zip"):
243
- return asset["download_url"]
244
- return None
245
-
246
- # ── HTML patcher ──────────────────────────────────────────────────────────────
247
-
248
- def _patch_download_html(html: str, linux_url: Optional[str], macos_url: Optional[str], windows_url: Optional[str]) -> str:
249
- """
250
- Replace placeholder href="#" + btn--coming-soon class on each OS download
251
- button with the real .zip URL. Skips an OS if its URL is None.
252
- Targets these exact patterns from download.html:
253
- href="#" class="btn-dl btn-dl--linux btn--coming-soon"
254
- href="#" class="btn-dl btn-dl--mac btn--coming-soon"
255
- href="#" class="btn-dl btn-dl--windows btn--coming-soon"
256
- """
257
- replacements = [
258
- ("linux", linux_url),
259
- ("mac", macos_url),
260
- ("windows", windows_url),
261
- ]
262
- for css_key, url in replacements:
263
- if not url:
264
- continue
265
- # Match: href="#" class="btn-dl btn-dl--{key} btn--coming-soon"
266
- # Replace with: href="URL" class="btn-dl btn-dl--{key}"
267
- html = re.sub(
268
- rf'href="#"\s+class="(btn-dl\s+btn-dl--{css_key})\s+btn--coming-soon"',
269
- f'href="{url}" class="\\1"',
270
- html,
271
- )
272
- return html
273
-
274
- # ── Binary download helper ────────────────────────────────────────────────────
275
-
276
- async def _download_assets(client: httpx.AsyncClient, release: dict, folder: Path) -> list[dict]:
277
- folder.mkdir(parents=True, exist_ok=True)
278
- results = []
279
- for asset in release.get("assets", []):
280
- dest = folder / asset["name"]
281
- async with client.stream("GET", asset["browser_download_url"], follow_redirects=True) as resp:
282
- resp.raise_for_status()
283
- with open(dest, "wb") as f:
284
- async for chunk in resp.aiter_bytes(65536):
285
- f.write(chunk)
286
- results.append({"name": asset["name"], "path": str(dest), "size_bytes": dest.stat().st_size})
287
- return results
288
-
289
- # ── Routes ────────────────────────────────────────────────────────────────────
290
-
291
- @app.get("/health", summary="Health check", tags=["Misc"])
292
- async def health():
293
- return {
294
- "status": "ok",
295
- "app_repo": f"{REPO_OWNER}/{REPO_NAME}",
296
- "website_repo": f"{WEBSITE_OWNER}/{WEBSITE_REPO}",
297
- "output_dir": str(OUTPUT_DIR),
298
- }
299
-
300
-
301
- @app.post("/update-file", summary="Create or update any file in the app repo", tags=["Repo"])
302
- async def update_file(req: UpdateFileRequest):
303
- async with httpx.AsyncClient(timeout=30) as client:
304
- sha = await _file_sha(client, req.path, req.branch)
305
- payload: dict = {
306
- "message": req.message,
307
- "content": _b64_json(req.content),
308
- "branch": req.branch,
309
- }
310
- if sha:
311
- payload["sha"] = sha
312
- result = await gh_put(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/contents/{req.path}", payload)
313
-
314
- return {
315
- "action": "updated" if sha else "created",
316
- "path": req.path,
317
- "branch": req.branch,
318
- "commit": result.get("commit", {}).get("sha"),
319
- "html_url": result.get("content", {}).get("html_url"),
320
- }
321
-
322
-
323
- @app.get("/releases", summary="List all releases with per-OS asset URLs", tags=["Releases"])
324
- async def list_releases():
325
- async with httpx.AsyncClient(timeout=30) as client:
326
- releases = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases")
327
- out = []
328
- for r in releases: # type: ignore[union-attr]
329
- out.append({
330
- "tag": r["tag_name"],
331
- "name": r["name"],
332
- "published_at": r.get("published_at"),
333
- "html_url": r["html_url"],
334
- "assets": [_asset_entry(a) for a in r.get("assets", [])],
335
- })
336
- return {"count": len(out), "releases": out}
337
-
338
-
339
- @app.get("/releases/{tag}", summary="Full detail for one release tag", tags=["Releases"])
340
- async def get_release(tag: str):
341
- async with httpx.AsyncClient(timeout=30) as client:
342
- release = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases/tags/{tag}")
343
- assets = [_asset_entry(a) for a in release.get("assets", [])] # type: ignore[union-attr]
344
- by_os: dict[str, list] = {}
345
- for a in assets:
346
- by_os.setdefault(a["os"], []).append(a)
347
- return {
348
- "tag": release["tag_name"], # type: ignore[index]
349
- "name": release["name"],
350
- "body": release.get("body"),
351
- "published_at": release.get("published_at"),
352
- "html_url": release["html_url"],
353
- "assets": assets,
354
- "by_os": by_os,
355
- }
356
-
357
-
358
- # ── /links ────────────────────────────────────────────────────────────────────
359
-
360
- @app.get("/links", summary="Direct download links for every asset across all releases", tags=["Download Links"])
361
- async def all_download_links():
362
- async with httpx.AsyncClient(timeout=30) as client:
363
- releases = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases")
364
- return {
365
- "repo": f"{REPO_OWNER}/{REPO_NAME}",
366
- "total_releases": len(releases), # type: ignore[arg-type]
367
- "fetched_at": datetime.now(timezone.utc).isoformat(),
368
- "releases": [_links_for_release(r) for r in releases], # type: ignore[union-attr]
369
- }
370
-
371
-
372
- @app.get("/links/latest", summary="Download links for the latest release", tags=["Download Links"])
373
- async def latest_download_links():
374
- async with httpx.AsyncClient(timeout=30) as client:
375
- release = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest")
376
- return {
377
- "repo": f"{REPO_OWNER}/{REPO_NAME}",
378
- "fetched_at": datetime.now(timezone.utc).isoformat(),
379
- **_links_for_release(release), # type: ignore[arg-type]
380
- }
381
-
382
-
383
- @app.get("/links/{tag}", summary="Download links for a specific release tag", tags=["Download Links"])
384
- async def tag_download_links(tag: str):
385
- async with httpx.AsyncClient(timeout=30) as client:
386
- release = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases/tags/{tag}")
387
- return {
388
- "repo": f"{REPO_OWNER}/{REPO_NAME}",
389
- "fetched_at": datetime.now(timezone.utc).isoformat(),
390
- **_links_for_release(release), # type: ignore[arg-type]
391
- }
392
-
393
-
394
- @app.post("/process-release/{tag}", summary="Download assets + write release JSON", tags=["Releases"])
395
- async def process_release(tag: str, req: ProcessReleaseRequest = ProcessReleaseRequest()):
396
- slug = _slug(tag)
397
- folder = OUTPUT_DIR / f"release{slug}"
398
-
399
- async with httpx.AsyncClient(timeout=600, follow_redirects=True) as client:
400
- release = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases/tags/{tag}")
401
- downloaded = await _download_assets(client, release, folder) if req.download_assets else [] # type: ignore[arg-type]
402
- if not req.download_assets:
403
- folder.mkdir(parents=True, exist_ok=True)
404
-
405
- metadata = _build_metadata(release, downloaded) # type: ignore[arg-type]
406
- json_path = folder / f"release{slug}.json"
407
- json_path.write_text(json.dumps(metadata, indent=2))
408
-
409
- return {
410
- "release_folder": str(folder),
411
- "assets_downloaded": len(downloaded),
412
- "metadata_json": str(json_path),
413
- "metadata": metadata,
414
- }
415
-
416
-
417
- # ── /trigger — full 6-step pipeline ──────────────────────────────────────────
418
- @app.post(
419
- "/trigger",
420
- summary="Full pipeline — version.json · v{ver}.json · local mirrors · download.html",
421
- tags=["Pipeline"],
422
- )
423
- async def trigger(req: TriggerRequest):
424
- """
425
- Six-step pipeline
426
- -----------------
427
- 1. Push **version.json** to `DrivePurge_` (bump version string).
428
- 2. Push **v{version}.json** to `DrivePurge_` (full download-links payload
429
- identical to what `/links/latest` returns — zip URLs per OS/arch).
430
- 3. Fetch the latest release's download links and embed them in the response.
431
- 4. Mirror all release binaries to `/tmp/releases/release{ver}/` (optional).
432
- 5. Write a local **release{ver}.json** metadata file for each release.
433
- 6. Fetch, patch, and push **download.html** in `drive-purge.github.io`
434
- — replaces `href="#"` + `btn--coming-soon` on each OS button with the
435
- real `.zip` download URL for that platform.
436
- """
437
- results: dict = {}
438
-
439
- async with httpx.AsyncClient(timeout=600, follow_redirects=True) as client:
440
-
441
- # ── Step 1: push version.json ─────────────────────────────────────────
442
- now_iso = datetime.now(timezone.utc).isoformat()
443
- # Schema: { "version", "details", "download-link" } — exactly as required
444
- new_vj = {
445
- "version": req.version,
446
- "details": req.details or "No details provided.",
447
- "download-link": "https://drive-purge.github.io/download.html",
448
- }
449
- sha_vj = await _file_sha(client, "version.json", req.branch)
450
- vj_payload: dict = {
451
- "message": f"chore: bump version to {req.version}",
452
- "content": _b64_json(new_vj),
453
- "branch": req.branch,
454
- }
455
- if sha_vj:
456
- vj_payload["sha"] = sha_vj
457
- cr_vj = await gh_put(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/contents/version.json", vj_payload)
458
- results["step1_version_json"] = {
459
- "file": "version.json",
460
- "action": "updated" if sha_vj else "created",
461
- "commit": cr_vj.get("commit", {}).get("sha"),
462
- "html_url": cr_vj.get("content", {}).get("html_url"),
463
- }
464
-
465
- # ── Step 2: push v{version}.json with full download links ─────────────
466
- latest_release = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest")
467
- links_payload = _links_for_release(latest_release) # type: ignore[arg-type]
468
- links_payload["generated_at"] = now_iso # stamp it
469
-
470
- ver_json_name = f"v{req.version}.json"
471
- sha_vlink = await _file_sha(client, ver_json_name, req.branch)
472
- vlink_payload: dict = {
473
- "message": f"chore: add {ver_json_name} download links",
474
- "content": _b64_json(links_payload),
475
- "branch": req.branch,
476
- }
477
- if sha_vlink:
478
- vlink_payload["sha"] = sha_vlink
479
- cr_vlink = await gh_put(
480
- client,
481
- f"/repos/{REPO_OWNER}/{REPO_NAME}/contents/{ver_json_name}",
482
- vlink_payload,
483
- )
484
- results["step2_version_links_json"] = {
485
- "file": ver_json_name,
486
- "action": "updated" if sha_vlink else "created",
487
- "commit": cr_vlink.get("commit", {}).get("sha"),
488
- "html_url": cr_vlink.get("content", {}).get("html_url"),
489
- }
490
-
491
- # ── Step 3: embed download links in response ──────────────────────────
492
- results["step3_download_links"] = {
493
- "repo": f"{REPO_OWNER}/{REPO_NAME}",
494
- "fetched_at": now_iso,
495
- **links_payload,
496
- }
497
-
498
- # ── Step 4 + 5: mirror releases locally ──────────────────────────────
499
- all_releases = await gh_get(client, f"/repos/{REPO_OWNER}/{REPO_NAME}/releases")
500
- results["step4_5_releases_found"] = len(all_releases) # type: ignore[arg-type]
501
- results["step4_5_releases"] = []
502
-
503
- for release in all_releases: # type: ignore[union-attr]
504
- r_tag = release["tag_name"]
505
- r_slug = _slug(r_tag)
506
- r_folder = OUTPUT_DIR / f"release{r_slug}"
507
-
508
- downloaded = (
509
- await _download_assets(client, release, r_folder)
510
- if req.download_assets else []
511
- )
512
- if not req.download_assets:
513
- r_folder.mkdir(parents=True, exist_ok=True)
514
-
515
- metadata = _build_metadata(release, downloaded)
516
- json_path = r_folder / f"release{r_slug}.json"
517
- json_path.write_text(json.dumps(metadata, indent=2))
518
-
519
- results["step4_5_releases"].append({
520
- "tag": r_tag,
521
- "folder": str(r_folder),
522
- "assets_downloaded": len(downloaded),
523
- "json_written": str(json_path),
524
- "os_breakdown": {k: len(v) for k, v in metadata["by_os"].items()},
525
- })
526
-
527
- # ── Step 6: patch download.html in drive-purge.github.io ─────────────
528
- by_os = links_payload["by_os"]
529
- linux_url = _pick_zip_url(by_os, "linux")
530
- macos_url = _pick_zip_url(by_os, "macos")
531
- win_url = _pick_zip_url(by_os, "windows")
532
-
533
- # Fetch current download.html
534
- html_file_data = await gh_get(
535
- client,
536
- f"/repos/{WEBSITE_OWNER}/{WEBSITE_REPO}/contents/download.html?ref=main",
537
- )
538
- current_sha_html = html_file_data.get("sha") # type: ignore[union-attr]
539
- raw_html = base64.b64decode( # type: ignore[union-attr]
540
- html_file_data["content"].replace("\n", "") # type: ignore[index]
541
- ).decode("utf-8")
542
-
543
- # Patch the three OS buttons
544
- patched_html = _patch_download_html(raw_html, linux_url, macos_url, win_url)
545
-
546
- # Count how many substitutions were actually made
547
- subs_made = sum(
548
- 1 for url in (linux_url, macos_url, win_url) if url
549
- )
550
-
551
- html_push_payload: dict = {
552
- "message": f"chore: update download links for v{req.version}",
553
- "content": _b64_html(patched_html),
554
- "branch": "main",
555
- }
556
- if current_sha_html:
557
- html_push_payload["sha"] = current_sha_html
558
-
559
- cr_html = await gh_put(
560
- client,
561
- f"/repos/{WEBSITE_OWNER}/{WEBSITE_REPO}/contents/download.html",
562
- html_push_payload,
563
- )
564
- results["step6_download_html"] = {
565
- "repo": f"{WEBSITE_OWNER}/{WEBSITE_REPO}",
566
- "file": "download.html",
567
- "buttons_updated": {
568
- "linux": linux_url,
569
- "macos": macos_url,
570
- "windows": win_url,
571
- },
572
- "substitutions_made": subs_made,
573
- "commit": cr_html.get("commit", {}).get("sha"),
574
- "html_url": cr_html.get("content", {}).get("html_url"),
575
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
 
577
- return results
578
-
579
-
580
- # ── Entry-point ───────────────────────────────────────────────────────────────
581
  if __name__ == "__main__":
582
- import uvicorn
583
- uvicorn.run(
584
- "main:app",
585
- host="0.0.0.0",
586
- port=int(os.getenv("PORT", "7860")),
587
- reload=False,
588
- )
 
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
+ if not os.path.exists(db_path):
8
+ return [], []
9
+ conn = sqlite3.connect(db_path)
10
+ cursor = conn.cursor()
11
+
12
+ cursor.execute('''CREATE TABLE IF NOT EXISTS links (
13
+ id INTEGER PRIMARY KEY,
14
+ url TEXT,
15
+ status TEXT DEFAULT 'pending'
16
+ )''')
 
 
17
  try:
18
+ cursor.execute("SELECT api_key FROM accounts")
19
+ api_keys = [row[0] for row in cursor.fetchall() if row[0]]
20
+ except Exception as e:
21
+ api_keys = []
22
+
23
+ try:
24
+ query = "SELECT id, url FROM links WHERE status='pending'"
25
+ if limit_links:
26
+ query += f" LIMIT {limit_links}"
27
+
28
+ cursor.execute(query)
29
+ link_records = cursor.fetchall()
30
+ links = [row[1] for row in link_records if row[1]]
31
+
32
+ if link_records:
33
+ ids = [str(row[0]) for row in link_records]
34
+ cursor.execute(f"UPDATE links SET status='processing' WHERE id IN ({','.join(ids)})")
35
+ conn.commit()
36
+ except Exception as e:
37
+ links = []
38
+
39
+ conn.close()
40
+ return api_keys, links
41
+
42
+ def trigger_workers(db_path, worker_urls_str, links_per_worker):
43
+ worker_urls = [u.strip() for u in worker_urls_str.split(',') if u.strip()]
44
+ if not worker_urls:
45
+ return "Error: No worker URLs provided."
46
+
47
+ api_keys, links = fetch_data(db_path, limit_links=int(links_per_worker) * len(worker_urls))
48
+ if not api_keys:
49
+ return "Error: No API keys found in DB."
50
+ if not links:
51
+ return "Error: No pending links found in DB."
52
+
53
+ link_chunks = [links[i:i + int(links_per_worker)] for i in range(0, len(links), int(links_per_worker))]
54
+
55
+ logs = []
56
+ logs.append(f"Loaded {len(api_keys)} API keys and {len(links)} links.")
57
+ logs.append(f"Divided into {len(link_chunks)} worker chunks.")
58
+
59
+ for idx, chunk in enumerate(link_chunks):
60
+ if idx >= len(worker_urls):
61
+ logs.append("More chunks than workers. Remaining chunks won't be sent.")
62
+ break
63
+
64
+ worker_url = worker_urls[idx]
65
+ api_key = api_keys[idx % len(api_keys)]
66
+
67
+ payload = {
68
+ "links": chunk,
69
+ "api_key": api_key
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  }
71
+
72
+ endpoint = f"{worker_url.rstrip('/')}/process"
73
+ try:
74
+ resp = requests.post(endpoint, json=payload, timeout=10)
75
+ logs.append(f"Sent {len(chunk)} links to {worker_url}: {resp.status_code} - {resp.text}")
76
+ except Exception as e:
77
+ logs.append(f"Failed to send to {worker_url}: {str(e)}")
78
+
79
+ return "\n".join(logs)
80
+
81
+ with gr.Blocks() as app:
82
+ gr.Markdown("# YouTube Video Orchestrator Host")
83
+ gr.Markdown("Deploy this to Hugging Face Spaces or run locally. It fetches pending links from your database and delegates them to FastAPI workers.")
84
+
85
+ with gr.Row():
86
+ db_input = gr.Textbox(label="DB Path", value="data/db/accounts.db")
87
+ workers_input = gr.Textbox(label="Worker URLs (comma separated)", placeholder="http://localhost:7860, https://your-worker.hf.space")
88
+ links_per_worker = gr.Number(label="Links per worker", value=2)
89
+
90
+ run_btn = gr.Button("Run Orchestration", variant="primary")
91
+ output_log = gr.Textbox(label="Logs", lines=10)
92
+
93
+ run_btn.click(fn=trigger_workers, inputs=[db_input, workers_input, links_per_worker], outputs=output_log)
94
 
 
 
 
 
95
  if __name__ == "__main__":
96
+ app.launch(server_name="0.0.0.0", server_port=7860)