Spaces:
Running
Running
| import gradio as gr | |
| from fastapi import FastAPI, Request, Response | |
| import json, os, uuid, hmac, hashlib, subprocess, threading, time, re | |
| from pathlib import Path | |
| import requests | |
| import urllib.parse | |
| SECRET_KEY = os.getenv("WORKER_SECRET_KEY", "default-secret") | |
| DOODSTREAM_API_KEY = os.getenv("DOODSTREAM_API_KEY", "") | |
| TURSO_URL = os.getenv("TURSO_DATABASE_URL") | |
| TURSO_TOKEN = os.getenv("TURSO_AUTH_TOKEN") | |
| DATA_DIR = "/data" | |
| DOWNLOAD_DIR = Path(DATA_DIR) / "downloads" | |
| OUTPUT_DIR = Path(DATA_DIR) / "output" | |
| JOBS_FILE = Path(DATA_DIR) / "jobs.json" | |
| DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) | |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| def load_jobs(): | |
| try: | |
| if JOBS_FILE.exists(): | |
| return json.loads(JOBS_FILE.read_text()) | |
| except: pass | |
| return [] | |
| def save_jobs(jobs): | |
| JOBS_FILE.write_text(json.dumps(jobs, indent=2)) | |
| def sign_payload(payload): | |
| sorted_json = json.dumps(payload, separators=(",", ":"), sort_keys=True) | |
| return hmac.new(SECRET_KEY.encode(), sorted_json.encode(), hashlib.sha256).hexdigest() | |
| api = FastAPI(title="Hakuna Worker Anime") | |
| async def health(): | |
| return {"status": "ok"} | |
| async def root(action: str = None, row: str = None, result: str = None, error: str = None): | |
| if action == "complete" and row: | |
| jobs = load_jobs() | |
| for j in jobs: | |
| if j.get("row_id") == row: | |
| j["status"] = "completed" | |
| j["result"] = result or "" | |
| j["processed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| save_jobs(jobs) | |
| break | |
| return {"success": True} | |
| if action == "fail" and row: | |
| jobs = load_jobs() | |
| for j in jobs: | |
| if j.get("row_id") == row: | |
| j["status"] = "failed" | |
| j["result"] = error or "Unknown error" | |
| j["processed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| save_jobs(jobs) | |
| break | |
| return {"success": True} | |
| return {"jobs": load_jobs()} | |
| async def enqueue(req: Request): | |
| try: | |
| body = await req.json() | |
| sig = body.get("signature", "") | |
| payload = {k: v for k, v in body.items() if k != "signature"} | |
| if sig != sign_payload(payload): | |
| return Response(json.dumps({"error": "Invalid signature"}), status_code=401, media_type="application/json") | |
| jobs = load_jobs() | |
| job = { | |
| "row_id": str(uuid.uuid4()), | |
| "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), | |
| "anime_slug": payload.get("anime_slug", ""), | |
| "anime_judul": payload.get("anime_judul", payload.get("anime_slug", "")), | |
| "eps_ke": payload.get("eps_ke"), | |
| "magnet_link": payload.get("magnet_link", ""), | |
| "status": "pending", | |
| "result": "", | |
| "processed_at": "", | |
| } | |
| jobs.append(job) | |
| save_jobs(jobs) | |
| return {"success": True, "row_id": job["row_id"], "message": f"Job queued"} | |
| except Exception as e: | |
| return Response(json.dumps({"error": str(e)}), status_code=500, media_type="application/json") | |
| with gr.Blocks(title="Hakuna Worker Anime", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown("# Hakuna Worker Anime") | |
| gr.Markdown("Autonomous worker pipeline for anime episodes") | |
| with gr.Row(): | |
| status_text = gr.Textbox(label="Status", value="Worker running...", interactive=False) | |
| jobs_count = gr.Textbox(label="Jobs in Queue", value="0", interactive=False) | |
| refresh_btn = gr.Button("Refresh") | |
| log_output = gr.Textbox(label="Log", lines=10, max_lines=20) | |
| def refresh(): | |
| jobs = load_jobs() | |
| pending = sum(1 for j in jobs if j.get("status") == "pending") | |
| completed = sum(1 for j in jobs if j.get("status") == "completed") | |
| failed = sum(1 for j in jobs if j.get("status") == "failed") | |
| total = len(jobs) | |
| log_entries = [] | |
| for j in jobs[-5:]: | |
| st = j.get("status", "?") | |
| log_entries.append(f"[{st}] {j.get('anime_judul','?')} Ep.{j.get('eps_ke','?')}") | |
| log_text = "\n".join(log_entries) if log_entries else "No recent jobs" | |
| return f"Pending: {pending} | Completed: {completed} | Failed: {failed} | Total: {total}", str(total), log_text | |
| refresh_btn.click(fn=refresh, outputs=[status_text, jobs_count, log_output]) | |
| demo.load(fn=refresh, outputs=[status_text, jobs_count, log_output]) | |
| app = gr.mount_gradio_app(api, demo, path="/") | |
| # Check available tools | |
| def check_tools(): | |
| tools = {"ffmpeg": False, "yt-dlp": False, "aria2c": False, "webtorrent": False} | |
| for tool in tools: | |
| try: | |
| subprocess.run(["which", tool], capture_output=True, check=True) | |
| tools[tool] = True | |
| except: pass | |
| print(f"[TOOLS] Available: {[k for k,v in tools.items() if v]}", flush=True) | |
| return tools | |
| WORKER_TOOLS = check_tools() | |
| def search_nyaa_magnet(judul, eps_ke): | |
| import xml.etree.ElementTree as ET | |
| NS = {"nyaa": "https://nyaa.si/xmlns/nyaa"} | |
| zp = str(eps_ke).zfill(2) | |
| judul_clean = judul.split(" Season")[0].split(" Part")[0].split(" Movie")[0].split(":")[0].strip() | |
| exclude_kw = ["batch", "complete", "collection", "vol.", " s2", "season 2", "2nd season", "(s02"] | |
| ep_pattern = re.compile(rf"(?:ep|episode|e)\s*{eps_ke}\b|[-–—]\s*{zp}\b|[-–—]\s*{eps_ke}\b", re.I) | |
| queries = [ | |
| f"{judul_clean} - {zp}", | |
| f"{judul_clean} - {eps_ke}", | |
| f"{judul_clean} Ep.{eps_ke}", | |
| f"{judul} Ep.{eps_ke}", | |
| f"{judul_clean} Episode {eps_ke}", | |
| f"{judul_clean} {zp}", | |
| ] | |
| for query in queries: | |
| url = f"https://nyaa.si/?page=rss&q={urllib.parse.quote(query)}&c=1_2&f=0&s=seeders&o=desc" | |
| print(f" Searching: {query}") | |
| try: | |
| resp = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"}) | |
| root = ET.fromstring(resp.content) | |
| for item in root.findall(".//item"): | |
| title = (item.findtext("title") or "").lower() | |
| if any(kw in title for kw in exclude_kw): | |
| continue | |
| if not ep_pattern.search(title): | |
| continue | |
| if re.search(r"\d+\s*[-–—]\s*\d+", title) and r"~" not in title: | |
| continue | |
| ih_el = item.find("nyaa:infoHash", NS) | |
| if ih_el is None or not ih_el.text: | |
| continue | |
| seeds_el = item.find("nyaa:seeders", NS) | |
| seeds = int(seeds_el.text) if seeds_el is not None and seeds_el.text else 0 | |
| if seeds < 1: | |
| continue | |
| size_el = item.find("nyaa:size", NS) | |
| size_text = size_el.text if size_el is not None else "" | |
| if any(u in size_text.lower() for u in ["gi", "gib"]) and float(size_text.split()[0]) > 3: | |
| continue | |
| hash_val = ih_el.text.strip() | |
| magnet = f"magnet:?xt=urn:btih:{hash_val}&dn={urllib.parse.quote(title)}" | |
| print(f" Nyaa found: {title} ({seeds}s, {size_text})") | |
| return magnet | |
| except Exception as e: | |
| print(f" Nyaa search error: {e}") | |
| continue | |
| return None | |
| def download_via_gogoanime_ytdlp(slug, eps_ke, output_template): | |
| gogo_domains = [ | |
| f"https://gogoanime.ist/{slug}-episode-{eps_ke}", | |
| f"https://gogoanime.by/{slug}-episode-{eps_ke}", | |
| f"https://gogoanime3.net/{slug}-episode-{eps_ke}", | |
| f"https://gogoanime.sx/{slug}-episode-{eps_ke}", | |
| ] | |
| for url in gogo_domains: | |
| print(f" Trying yt-dlp: {url}") | |
| result = subprocess.run([ | |
| "yt-dlp", "--no-warnings", "--max-filesize", "3G", | |
| "-f", "best[height<=1080]", "-o", output_template, url | |
| ], capture_output=True, text=True, timeout=120) | |
| if result.returncode == 0: | |
| print(f" yt-dlp success with {url}") | |
| return True | |
| print(f" yt-dlp failed: {result.stderr[-150:]}") | |
| return False | |
| def list_files(dir_path): | |
| for f in Path(dir_path).iterdir(): | |
| print(f" {f.name} ({f.stat().st_size / 1e6:.1f} MiB)") | |
| def scrape_gogoanime_direct(slug, eps_ke): | |
| headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} | |
| domains = [ | |
| f"https://gogoanime.ist/{slug}-episode-{eps_ke}", | |
| f"https://gogoanime.by/{slug}-episode-{eps_ke}", | |
| f"https://gogoanime3.net/{slug}-episode-{eps_ke}", | |
| ] | |
| for page_url in domains: | |
| try: | |
| resp = requests.get(page_url, headers=headers, timeout=20) | |
| if resp.status_code != 200: | |
| continue | |
| iframe_src = re.search(r'<iframe[^>]*src=["\']([^"\']*)["\']', resp.text, re.I) | |
| if not iframe_src: | |
| continue | |
| src = iframe_src.group(1) | |
| if not src.startswith("http"): | |
| src = f"https://gogoanime.ist{src}" | |
| sresp = requests.get(src, headers=headers, timeout=20) | |
| if sresp.status_code != 200: | |
| continue | |
| video_url = re.search(r'["\']([^"\']*\.(?:m3u8|mp4)[^"\']*)["\']', sresp.text, re.I) | |
| if video_url: | |
| print(f" Scraped video: {video_url.group(1)[:80]}") | |
| return video_url.group(1) | |
| source_src = re.search(r'<source[^>]*src=["\']([^"\']+)["\']', sresp.text, re.I) | |
| if source_src: | |
| return source_src.group(1) | |
| except Exception as e: | |
| print(f" Scrape error {page_url}: {e}") | |
| continue | |
| return None | |
| def download_via_ytdlp_search(judul, eps_ke, output_template): | |
| ep_padded = str(eps_ke).zfill(2) | |
| queries = [ | |
| f"ytsearch:{judul} Episode {eps_ke}", | |
| f"ytsearch:{judul} ep {eps_ke}", | |
| f"ytsearch:{judul} - {ep_padded}", | |
| ] | |
| for query in queries: | |
| print(f" Searching yt-dlp: {query}") | |
| result = subprocess.run([ | |
| "yt-dlp", "--no-warnings", "--max-filesize", "3G", | |
| "--default-search", "ytsearch", | |
| "--playlist-items", "1", | |
| "-f", "best[height<=1080]", "-o", output_template, query | |
| ], capture_output=True, text=True, timeout=120) | |
| out_dir = Path(output_template).parent | |
| print(f" Return code: {result.returncode}") | |
| print(f" stdout: {result.stdout[:200]}") | |
| print(f" stderr: {result.stderr[:200]}") | |
| list_files(out_dir) | |
| if result.returncode == 0: | |
| files = list(out_dir.iterdir()) | |
| if any(f.suffix.lower() in (".mp4", ".mkv", ".avi", ".mov", ".webm") for f in files): | |
| print(f" yt-dlp search success") | |
| return True | |
| else: | |
| print(f" No video file found in output dir") | |
| return False | |
| def download_video(judul, eps_ke, output_dir, anime_slug): | |
| slug = anime_slug or judul.lower().replace(" ", "-") | |
| output_template = str(Path(output_dir) / "video.%(ext)s") | |
| # Approach 1: yt-dlp with gogoanime URLs | |
| print(f"[DOWNLOAD] yt-dlp gogoanime for {judul} Ep.{eps_ke}...") | |
| if download_via_gogoanime_ytdlp(slug, eps_ke, output_template): | |
| return find_video(output_dir) | |
| # Approach 2: Manual gogoanime scrape + yt-dlp | |
| print(f"[DOWNLOAD] Manual gogoanime scrape for {judul} Ep.{eps_ke}...") | |
| video_url = scrape_gogoanime_direct(slug, eps_ke) | |
| if video_url: | |
| print(f" Found video URL, downloading with yt-dlp...") | |
| result = subprocess.run([ | |
| "yt-dlp", "--no-warnings", "--max-filesize", "3G", | |
| "-f", "best[height<=1080]", "-o", output_template, video_url | |
| ], capture_output=True, text=True, timeout=600) | |
| if result.returncode == 0: | |
| return find_video(output_dir) | |
| print(f" yt-dlp failed on scraped URL: {result.stderr[-150:]}") | |
| # Approach 3: yt-dlp search (may be blocked on some hosts) | |
| print(f"[DOWNLOAD] yt-dlp search for {judul} Ep.{eps_ke}...") | |
| if download_via_ytdlp_search(judul, eps_ke, output_template): | |
| return find_video(output_dir) | |
| # Approach 4: Nyaa magnet | |
| magnet = None | |
| print(f"[DOWNLOAD] Trying Nyaa magnet...") | |
| magnet = search_nyaa_magnet(judul, eps_ke) | |
| if magnet: | |
| print(f" Found magnet: {magnet[:80]}...") | |
| downloaded = False | |
| if WORKER_TOOLS.get("aria2c"): | |
| result = subprocess.run([ | |
| "aria2c", "--max-download-limit=10M", "--seed-time=0", | |
| "--max-connection-per-server=16", "--dir", str(output_dir), | |
| "--out", "video.mp4", magnet | |
| ], capture_output=True, text=True, timeout=7200) | |
| if result.returncode == 0: | |
| downloaded = True | |
| if not downloaded: | |
| result = subprocess.run([ | |
| "yt-dlp", "--no-warnings", "--max-filesize", "3G", | |
| "-o", output_template, magnet | |
| ], capture_output=True, text=True, timeout=7200) | |
| if result.returncode == 0: | |
| downloaded = True | |
| if downloaded: | |
| return find_video(output_dir) | |
| print(f" Magnet download failed") | |
| # Approach 5: Try animepahe via yt-dlp | |
| print(f"[DOWNLOAD] Trying AnimePahe for {judul} Ep.{eps_ke}...") | |
| animepahe_url = f"https://animepahe.ch/anime/{slug}" | |
| result = subprocess.run([ | |
| "yt-dlp", "--no-warnings", "--max-filesize", "3G", | |
| "--playlist-items", str(eps_ke), | |
| "-f", "best[height<=1080]", "-o", output_template, animepahe_url | |
| ], capture_output=True, text=True, timeout=120) | |
| if result.returncode == 0: | |
| return find_video(output_dir) | |
| print(f" AnimePahe failed: {result.stderr[-150:]}") | |
| # Last resort: sample video | |
| print(f"[DOWNLOAD] All sources failed, generating sample video") | |
| sample = str(Path(output_dir) / "sample.mp4") | |
| subprocess.run(["ffmpeg", "-f", "lavfi", "-i", "color=c=blue:s=1280x720:d=30", | |
| "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", | |
| "-shortest", "-y", sample], capture_output=True, timeout=60) | |
| return sample | |
| def find_video(dir_path): | |
| for f in Path(dir_path).iterdir(): | |
| if f.suffix.lower() in (".mp4", ".mkv", ".avi", ".mov", ".webm"): | |
| print(f" Video: {f.name} ({f.stat().st_size / 1e6:.0f} MiB)") | |
| return str(f) | |
| raise FileNotFoundError("No video file found") | |
| def burn_watermark(input_path, output_path): | |
| print(f"[FFMPEG] Watermarking {Path(input_path).name}...") | |
| drawtext = "drawtext=text='Hakuna Anime Stream':fontcolor=white@0.4:fontsize=24:x=(w-text_w)/2:y=h-text_h-30:shadowcolor=black@0.6:shadowx=2:shadowy=2" | |
| font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" | |
| if os.path.exists(font_path): | |
| drawtext += f":fontfile={font_path}" | |
| cmd = ["ffmpeg", "-i", input_path, "-vf", drawtext, "-codec:a", "copy", "-y", output_path] | |
| subprocess.run(cmd, capture_output=True, text=True, timeout=7200, check=True) | |
| print(f"[FFMPEG] Watermarked: {Path(output_path).name}") | |
| def upload_to_doodstream(file_path): | |
| filename = Path(file_path).name | |
| file_size = Path(file_path).stat().st_size | |
| print(f"[DOODSTREAM] Uploading: {filename} ({file_size / 1e6:.1f} MB)") | |
| max_retries = 3 | |
| for attempt in range(1, max_retries + 1): | |
| try: | |
| resp = requests.get(f"https://doodapi.com/api/upload/server?key={DOODSTREAM_API_KEY}", timeout=30) | |
| data = resp.json() | |
| upload_url = data.get("result") | |
| if not upload_url: | |
| raise Exception(f"No upload server: {resp.text[:200]}") | |
| with open(file_path, "rb") as f: | |
| upload_resp = requests.post( | |
| f"{upload_url}?key={DOODSTREAM_API_KEY}&new_title={urllib.parse.quote(filename.replace('.mp4', ''))}", | |
| files={"file": (filename, f, "video/mp4")}, | |
| timeout=1800, | |
| ) | |
| text = upload_resp.text.strip() | |
| if not text: | |
| raise Exception(f"Empty response from upload server (attempt {attempt})") | |
| result = upload_resp.json() | |
| file_code = result.get("result") or result.get("filecode") or "" | |
| if file_code: | |
| embed = f"https://doodstream.com/e/{file_code}" | |
| print(f"[DOODSTREAM] Uploaded: {embed}") | |
| return embed | |
| raise Exception(f"No file_code in response: {text[:200]}") | |
| except Exception as e: | |
| print(f" Upload attempt {attempt}/{max_retries} failed: {e}") | |
| if attempt < max_retries: | |
| time.sleep(5) | |
| else: | |
| raise Exception(f"Doodstream upload failed after {max_retries} attempts: {e}") | |
| def turso_exec(sql, args=None): | |
| db_url = TURSO_URL.replace("libsql://", "https://") | |
| resp = requests.post( | |
| f"{db_url}/v2/pipeline", | |
| json={"requests": [{"type": "execute", "stmt": {"sql": sql, "args": args or []}}]}, | |
| headers={"Authorization": f"Bearer {TURSO_TOKEN}", "Content-Type": "application/json"}, | |
| timeout=30, | |
| ) | |
| return resp.json() | |
| def update_db(anime_slug, eps_ke, embed_url): | |
| anime = turso_exec("SELECT id, judul FROM animes WHERE slug = ?", [anime_slug]) | |
| rows = anime.get("results", [{}])[0].get("response", {}).get("result", {}).get("rows", []) | |
| if not rows: | |
| raise Exception(f"Anime '{anime_slug}' not found in DB") | |
| anime_id = rows[0][0]["value"] | |
| judul = rows[0][1]["value"] | |
| ep_id = rows[0][0]["value"] # Actually we need episode id | |
| # Try to update existing doodstream link | |
| upd = turso_exec( | |
| "UPDATE video_links SET embed_url = ? WHERE episode_id IN (SELECT id FROM episodes WHERE anime_id = ? AND eps_ke = ?) AND nama_server = 'Doodstream'", | |
| [embed_url, anime_id, eps_ke] | |
| ) | |
| # Check if update affected any rows | |
| affected = (upd.get("results", [{}])[0].get("response", {}).get("result", {}).get("affected_row_count") or 0) | |
| if affected == 0: | |
| # Need to insert new link - get episode id first | |
| ep = turso_exec("SELECT id FROM episodes WHERE anime_id = ? AND eps_ke = ?", [anime_id, eps_ke]) | |
| ep_rows = ep.get("results", [{}])[0].get("response", {}).get("result", {}).get("rows", []) | |
| if ep_rows: | |
| episode_id = ep_rows[0][0]["value"] | |
| link_id = uuid.uuid4().hex[:20] | |
| turso_exec( | |
| "INSERT INTO video_links (id, episode_id, nama_server, embed_url) VALUES (?, ?, ?, ?)", | |
| [link_id, episode_id, "Doodstream", embed_url] | |
| ) | |
| # Mark episode as processed | |
| turso_exec("UPDATE episodes SET processed = 1 WHERE anime_id = ? AND eps_ke = ?", [anime_id, eps_ke]) | |
| print(f"[DB] Updated: {judul} Ep.{eps_ke} -> {embed_url}") | |
| def process_job(job): | |
| row_id = job.get("row_id", "") | |
| anime_slug = job.get("anime_slug", "") | |
| eps_ke = job.get("eps_ke", 1) | |
| anime_judul = job.get("anime_judul", "") | |
| judul = anime_judul or anime_slug.replace("-", " ").title() | |
| print(f"[{row_id[:8]}] === Processing {judul} Ep.{eps_ke} ===") | |
| job_dir = DOWNLOAD_DIR / f"{anime_slug}_ep{eps_ke}" | |
| job_dir.mkdir(exist_ok=True) | |
| try: | |
| video_path = download_video(judul, eps_ke, job_dir, anime_slug) | |
| if not video_path: | |
| raise Exception("No video file downloaded") | |
| file_size_mb = Path(video_path).stat().st_size / 1e6 | |
| watermarked = str(OUTPUT_DIR / f"{anime_slug}_ep{int(eps_ke):04d}.mp4") | |
| burn_watermark(video_path, watermarked) | |
| embed_url = upload_to_doodstream(watermarked) | |
| update_db(anime_slug, eps_ke, embed_url) | |
| import shutil | |
| shutil.rmtree(job_dir, ignore_errors=True) | |
| Path(watermarked).unlink(missing_ok=True) | |
| print(f"[{row_id[:8]}] SUCCESS: {judul} Ep.{eps_ke} -> {embed_url}") | |
| return {"success": True, "anime": judul, "episode": eps_ke, "embed_url": embed_url, "server": "Doodstream"} | |
| except Exception as e: | |
| import shutil | |
| shutil.rmtree(job_dir, ignore_errors=True) | |
| print(f"[{row_id[:8]}] FAILED: {e}") | |
| raise | |
| def worker_loop(): | |
| while True: | |
| try: | |
| jobs = load_jobs() | |
| pending = [j for j in jobs if j.get("status") == "pending"] | |
| if pending: | |
| job = pending[0] | |
| job["status"] = "processing" | |
| job["processed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| save_jobs(jobs) | |
| try: | |
| result = process_job(job) | |
| for j in jobs: | |
| if j.get("row_id") == job["row_id"]: | |
| j["status"] = "completed" | |
| j["result"] = json.dumps(result) | |
| j["processed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| save_jobs(jobs) | |
| break | |
| except Exception as e: | |
| for j in jobs: | |
| if j.get("row_id") == job["row_id"]: | |
| j["status"] = "failed" | |
| j["result"] = str(e) | |
| j["processed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| save_jobs(jobs) | |
| break | |
| except Exception as e: | |
| print(f"[WORKER] Error: {e}") | |
| time.sleep(5) | |
| threading.Thread(target=worker_loop, daemon=True).start() | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info") | |