"""Voice sources scraping, proxy-audio, quick-import, yt-dlp download, ElevenLabs.""" from __future__ import annotations import asyncio import glob import json import re import uuid from datetime import datetime, timezone from html import unescape from pathlib import Path from urllib.parse import quote, urljoin, urlparse import requests from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from core.config import _load_settings from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR from core.registry import _registry_put, TEMP_DIR from core.validation import _validate_http_url from core.audio import _to_wav_24k, _duration from core.voice import ( _AUDIO_EXTS, _active_voices_dir, _remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta, ) router = APIRouter() # ── Voice source helpers ────────────────────────────────────────────────────── _VOICE_SOURCE_HEADERS = { "User-Agent": "TTS Voice Creator voice-source-scraper/1.0 (+local)", "Accept": "text/html,application/json,text/plain;q=0.9,*/*;q=0.8", } DEFAULT_VOICE_SOURCE_URLS = [ "https://aiartes.com/voiceai", "https://sample-files.com/downloads/audio/wav/voice-sample.wav", "https://freesound.org/people/Scott%20Simpson/", "https://lanceblairvo.com/raw-voiceover-samples/", "https://github.com/yaph/tts-samples/tree/main/mp3", "https://github.com/jim-schwoebel/voice_datasets", ] _DIRECT_AUDIO_RE = re.compile(r"\.(?:mp3|wav|ogg|flac|m4a|aac)(?:$|[?#])", re.I) _LANG_HINTS = { "english": "English", "german": "German", "deutsch": "German", "french": "French", "spanish": "Spanish", "italian": "Italian", "portuguese": "Portuguese", "dutch": "Dutch", "polish": "Polish", "russian": "Russian", "japanese": "Japanese", "korean": "Korean", "chinese": "Chinese", "arabic": "Arabic", "swedish": "Swedish", "turkish": "Turkish", "hindi": "Hindi", } _PROXY_AUDIO_DOMAINS: set[str] = { "drive.usercontent.google.com", "drive.google.com", "aiartes.com", "freesound.org", "lanceblairvo.com", "raw.githubusercontent.com", "sample-files.com", "platform.r2.fish.audio", "fish.audio", } _GDRIVE_MIME_EXT = { "audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/wav": "wav", "audio/x-wav": "wav", "audio/ogg": "ogg", "audio/vorbis": "ogg", "audio/flac": "flac", "audio/x-flac": "flac", "audio/aac": "aac", "audio/x-aac": "aac", "audio/x-m4a": "m4a", "audio/m4a": "m4a", "audio/mp4": "m4a", } def _source_import_url(value: str) -> str: raw = str(value or "").strip() if not re.match(r"^https?://", raw, re.I): raise HTTPException(400, "Expected an http(s) URL") return raw def _source_download_limit(name: str, fallback_mb: int) -> int: import os value = globals().get(name) if isinstance(value, int): return value return int(os.environ.get(name.lstrip("_").replace("_BYTES", "_MB"), str(fallback_mb))) * 1024 * 1024 def _source_get(url: str, timeout: int = 12) -> requests.Response: r = requests.get(url, headers=_VOICE_SOURCE_HEADERS, timeout=timeout) r.raise_for_status() return r def _plain_text(value: str) -> str: value = re.sub(r"<[^>]+>", " ", str(value or "")) value = unescape(value) return re.sub(r"\s+", " ", value).strip() def _voice_source_id(url: str) -> str: parsed = urlparse(url) base = (parsed.netloc + parsed.path).strip("/").lower() base = re.sub(r"[^a-z0-9]+", "-", base).strip("-") return base[:80] or "source" def _source_name_from_url(url: str) -> str: parsed = urlparse(url) host = parsed.netloc.replace("www.", "") tail = Path(parsed.path.rstrip("/")).stem.replace("-", " ").replace("_", " ").strip() return f"{host} / {tail}" if tail else host or url def _guess_language(*values: str) -> str: text = " ".join(str(v or "") for v in values).lower() for key, label in _LANG_HINTS.items(): if re.search(rf"\b{re.escape(key)}\b", text): return label code = re.search(r"(?:^|[^a-z])(en|de|fr|es|it|pt|nl|pl|ru|ja|ko|zh|ar|sv|tr|hi)(?:[^a-z]|$)", text) return {"en": "English", "de": "German", "fr": "French", "es": "Spanish", "it": "Italian", "pt": "Portuguese", "nl": "Dutch", "pl": "Polish", "ru": "Russian", "ja": "Japanese", "ko": "Korean", "zh": "Chinese", "ar": "Arabic", "sv": "Swedish", "tr": "Turkish", "hi": "Hindi"}.get(code.group(1), "Unknown") if code else "Unknown" def _guess_gender(*values: str) -> str: text = " ".join(str(v or "") for v in values).lower() if re.search(r"\b(female|woman|girl|fem|_f_|-f-)\b", text): return "Female" if re.search(r"\b(male|man|boy|masc|_m_|-m-)\b", text): return "Male" return "Unknown" def _source_item(source_id: str, source_name: str, name: str, kind: str, page_url: str, audio_url: str = "", image_url: str = "", category: str = "", description: str = "", file_type: str = "", language: str = "", gender: str = "") -> dict: if not file_type and audio_url: match = re.search(r"\.([A-Za-z0-9]+)(?:$|[?#])", audio_url) file_type = match.group(1).lower() if match else "audio" language = language or _guess_language(name, kind, category, description, page_url, audio_url) gender = gender or _guess_gender(name, kind, category, description, page_url, audio_url) return { "id": f"{source_id}:{uuid.uuid5(uuid.NAMESPACE_URL, page_url + audio_url + name + kind)}", "source_id": source_id, "source": source_name, "name": _plain_text(name)[:160], "kind": _plain_text(kind)[:80], "category": _plain_text(category)[:80], "description": _plain_text(description)[:420], "page_url": page_url, "audio_url": audio_url, "image_url": image_url, "file_type": file_type, "language": language, "gender": gender, "direct_audio": bool(audio_url), } def _source_result(source_id: str, name: str, homepage: str, description: str, items: list[dict]) -> dict: return { "id": source_id, "name": name, "homepage": homepage, "description": description, "items": items, "count": len(items), "direct_audio": sum(1 for item in items if item.get("direct_audio")), } def _direct_audio_source(url: str) -> dict: source_id = _voice_source_id(url) name = _source_name_from_url(url) item = _source_item( source_id, name, Path(urlparse(url).path).stem.replace("-", " ").replace("_", " ") or "Voice sample", "Direct audio file", url, audio_url=url, category="Direct audio", description="Direct audio URL from the editable source list.", ) return _source_result(source_id, name, url, "Single direct audio URL.", [item]) def _generic_audio_page_source(url: str) -> dict: source_id = _voice_source_id(url) source_name = _source_name_from_url(url) body = _source_get(url).text title = re.search(r"]*>(.*?)", body, re.I | re.S) page_title = _plain_text(title.group(1)) if title else source_name seen = set() items = [] for match in re.finditer(r"""(?:src|href)=["']([^"']+\.(?:mp3|wav|ogg|flac|m4a|aac)(?:[^"']*)?)["']""", body, re.I): audio_url = urljoin(url, unescape(match.group(1))) if audio_url in seen: continue seen.add(audio_url) stem = Path(urlparse(audio_url).path).stem.replace("-", " ").replace("_", " ").strip() or page_title items.append(_source_item( source_id, source_name, stem, "Audio sample", url, audio_url=audio_url, category="Page audio", description=f"Audio link found on {page_title}.", )) if not items: items.append(_source_item( source_id, source_name, page_title, "Source page", url, category="Source page", description="No direct audio file links were found. Open the source page for previews, licensing, and download details.", )) return _source_result(source_id, source_name, url, f"Audio links scraped from {page_title}.", items) def _scrape_aiartes_voiceai() -> dict: homepage = "https://aiartes.com/voiceai" body = _source_get(homepage).text items = [] for card in body.split('
')[1:]: title = re.search(r'
\s*([^<]+?)\s*
', card, re.S) if not title: continue image = re.search(r']+src="([^"]+)"', card, re.S) image_url = urljoin(homepage, image.group(1)) if image else "" pairs = re.findall( r'
\s*([^<]+?)\s*
\s*]*>\s* dict: return _direct_audio_source("https://sample-files.com/downloads/audio/wav/voice-sample.wav") def _scrape_freesound_scott_simpson() -> dict: return _generic_audio_page_source("https://freesound.org/people/Scott%20Simpson/") def _scrape_lanceblair_raw_samples() -> dict: return _generic_audio_page_source("https://lanceblairvo.com/raw-voiceover-samples/") def _scrape_yaph_tts_samples() -> dict: homepage = "https://github.com/yaph/tts-samples/tree/main/mp3" api = "https://api.github.com/repos/yaph/tts-samples/git/trees/main?recursive=1" data = _source_get(api).json() items = [] for entry in data.get("tree", []): path = entry.get("path", "") if entry.get("type") != "blob" or not path.lower().startswith("mp3/") or not path.lower().endswith(".mp3"): continue parts = path.split("/") language = parts[1] if len(parts) > 2 else "Unknown" stem = Path(path).stem.replace("_", " ").replace("-", " ").strip() or Path(path).stem raw_path = quote(path, safe="/") raw_url = f"https://raw.githubusercontent.com/yaph/tts-samples/main/{raw_path}" page_url = f"https://github.com/yaph/tts-samples/blob/main/{raw_path}" items.append(_source_item( "yaph-tts-samples", "yaph/tts-samples", stem, "MP3 sample", page_url, audio_url=raw_url, category=language, description=f"Language folder: {language}. Synthetic TTS sample MP3 from yaph/tts-samples.", file_type="mp3", language=language, )) items.sort(key=lambda x: (x.get("category", ""), x.get("name", ""))) return { "id": "yaph-tts-samples", "name": "yaph/tts-samples mp3", "homepage": homepage, "description": "GitHub-hosted TTS sample MP3 files grouped by language.", "items": items, "count": len(items), "direct_audio": len(items), } def _scrape_jim_voice_datasets() -> dict: homepage = "https://github.com/jim-schwoebel/voice_datasets" raw = "https://raw.githubusercontent.com/jim-schwoebel/voice_datasets/master/README.md" text = _source_get(raw).text speech = text start = text.find("### Speech datasets") end = text.find("### Audio events", start if start >= 0 else 0) if start >= 0: speech = text[start:end if end >= 0 else len(text)] items = [] for name, url, desc in re.findall(r"^\* \[([^\]]+)\]\(([^)]+)\)\s*-\s*(.+)$", speech, re.M): items.append(_source_item( "voice-datasets", "jim-schwoebel/voice_datasets", name, "Dataset link", urljoin(homepage, url.strip()), category="Speech dataset", description=desc, )) return { "id": "voice-datasets", "name": "jim-schwoebel/voice_datasets", "homepage": homepage, "description": "Curated speech and voice dataset links from the repository README.", "items": items, "count": len(items), "direct_audio": 0, } def _scrape_google_drive_folder(url: str) -> dict: m = re.search(r"/folders/([A-Za-z0-9_-]+)", url) if not m: return _generic_audio_page_source(url) folder_id = m.group(1) source_id = _voice_source_id(url) source_name = "Google Drive" embed_url = f"https://drive.google.com/embeddedfolderview?id={folder_id}#list" try: body = _source_get(embed_url).text except Exception: return _generic_audio_page_source(url) items = [] seen: set[str] = set() for chunk in re.split(r"(?=
(.*?)
', chunk) mime_m = re.search(r"type/([a-zA-Z0-9/+\-]+)", chunk) if not eid or not title_m: continue file_id = eid.group(1) if file_id in seen: continue seen.add(file_id) filename = title_m.group(1).strip() mime = mime_m.group(1).lower() if mime_m else "" if not mime.startswith("audio"): continue ext = _GDRIVE_MIME_EXT.get(mime, Path(filename).suffix.lstrip(".").lower() or "mp3") gdrive_dl = f"https://drive.usercontent.google.com/download?id={file_id}&export=download&authuser=0" proxy_url = f"/api/proxy-audio?url={quote(gdrive_dl)}" stem = Path(filename).stem.replace("-", " ").replace("_", " ").strip() lang = _guess_language(filename) gender = _guess_gender(filename) item = _source_item( source_id, source_name, stem, "Google Drive audio", url, audio_url=proxy_url, file_type=ext, category="Google Drive", description="Audio file from Google Drive folder.", language=lang, gender=gender, ) item["import_url"] = gdrive_dl items.append(item) if not items: items.append(_source_item( source_id, source_name, "Google Drive folder", "Source page", url, category="Google Drive", description="No audio files found in this Google Drive folder. It may be private or empty.", )) return _source_result(source_id, source_name, url, f"{len(items)} audio files from Google Drive folder.", items) def _scrape_voice_source_url(url: str) -> dict: lower = url.lower() if "aiartes.com/voiceai" in lower: return _scrape_aiartes_voiceai() if "github.com/yaph/tts-samples" in lower: return _scrape_yaph_tts_samples() if "github.com/jim-schwoebel/voice_datasets" in lower: return _scrape_jim_voice_datasets() if "sample-files.com/downloads/audio/wav/voice-sample.wav" in lower: return _scrape_sample_files_voice_sample() if "freesound.org/people/scott%20simpson" in lower or "freesound.org/people/scott simpson" in lower: return _scrape_freesound_scott_simpson() if "lanceblairvo.com/raw-voiceover-samples" in lower: return _scrape_lanceblair_raw_samples() if "drive.google.com/drive/folders/" in lower or "drive.google.com/open?id=" in lower: return _scrape_google_drive_folder(url) if _DIRECT_AUDIO_RE.search(lower): return _direct_audio_source(url) return _generic_audio_page_source(url) def _normalize_voice_source_urls(urls: list[str] | None = None) -> list[str]: raw_urls = urls or DEFAULT_VOICE_SOURCE_URLS normalized = [] seen = set() for value in raw_urls: url = str(value or "").strip() if not url or not re.match(r"^https?://", url, re.I): continue if url not in seen: seen.add(url) normalized.append(url) return normalized[:24] def _voice_sources_payload(urls: list[str] | None = None) -> dict: sources = [] errors = [] source_urls = _normalize_voice_source_urls(urls) for url in source_urls: try: sources.append(_scrape_voice_source_url(url)) except Exception as e: errors.append({"source": url, "detail": str(e)}) return { "scraped_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "source_urls": source_urls, "sources": sources, "total": sum(src.get("count", 0) for src in sources), "direct_audio": sum(src.get("direct_audio", 0) for src in sources), "errors": errors, } # ── Routes ──────────────────────────────────────────────────────────────────── @router.get("/api/voice-sources") async def voice_sources(): return _voice_sources_payload(DEFAULT_VOICE_SOURCE_URLS) @router.post("/api/voice-sources") async def voice_sources_custom(request: Request): data = await request.json() return _voice_sources_payload(data.get("urls") if isinstance(data, dict) else None) @router.post("/api/import-source-audio") async def import_source_audio(request: Request): data = await request.json() audio_url = _source_import_url(str(data.get("audio_url") or "")) filename = str(data.get("name") or Path(urlparse(audio_url).path).name or "source-audio").strip() suffix = Path(urlparse(audio_url).path).suffix.lower() if suffix not in _AUDIO_EXTS: suffix = ".bin" dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" try: with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r: r.raise_for_status() total = 0 with dest.open("wb") as f: for chunk in r.iter_content(1024 * 1024): if not chunk: continue total += len(chunk) if total > _MAX_UPLOAD_BYTES: raise HTTPException(413, "Downloaded audio is too large") f.write(chunk) wav = _to_wav_24k(dest) except HTTPException: raise except Exception as e: raise HTTPException(400, f"Source audio import failed: {e}") fid = uuid.uuid4().hex _registry_put(fid, wav) return {"id": fid, "duration": _duration(wav), "filename": filename or dest.name, "audio_url": audio_url} @router.get("/api/proxy-audio") async def proxy_audio(url: str): parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise HTTPException(400, "Only http/https URLs are supported") domain = parsed.netloc.lower().lstrip("www.") if not any(domain == d or domain.endswith("." + d) for d in _PROXY_AUDIO_DOMAINS): raise HTTPException(403, f"Domain not in audio proxy allowlist: {parsed.netloc}") try: resp = requests.get(url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) resp.raise_for_status() except Exception as e: raise HTTPException(502, f"Proxy fetch failed: {e}") content_type = resp.headers.get("content-type", "audio/mpeg") def _stream(): for chunk in resp.iter_content(65536): if chunk: yield chunk return StreamingResponse( _stream(), media_type=content_type, headers={"Cache-Control": "public, max-age=3600"}, ) @router.post("/api/quick-import-voice") async def quick_import_voice(request: Request): data = await request.json() audio_url = _source_import_url(str(data.get("audio_url") or "")) voice_id = re.sub(r"[^A-Za-z0-9_\-\.]", "_", str(data.get("voice_id") or "").strip())[:80] if not voice_id: raise HTTPException(400, "voice_id is required") transcript = str(data.get("transcript") or "").strip() suffix = Path(urlparse(audio_url).path).suffix.lower() if suffix not in _AUDIO_EXTS: suffix = ".bin" dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" try: with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r: r.raise_for_status() total = 0 with dest.open("wb") as f: for chunk in r.iter_content(1024 * 1024): if not chunk: continue total += len(chunk) if total > _MAX_UPLOAD_BYTES: raise HTTPException(413, "Downloaded audio is too large") f.write(chunk) wav = _to_wav_24k(dest) except HTTPException: raise except Exception as e: raise HTTPException(400, f"Audio download failed: {e}") settings = _load_settings() out_dir = _active_voices_dir(settings) out_dir.mkdir(parents=True, exist_ok=True) final_id = voice_id if (out_dir / f"{final_id}.wav").exists(): for i in range(2, 1000): candidate = f"{voice_id}_{i}" if not (out_dir / f"{candidate}.wav").exists(): final_id = candidate break wav_dest = out_dir / f"{final_id}.wav" _remove_audio_variants(out_dir, final_id) from core.audio import _export_normalized_wav as _enw loudness = _enw(wav, wav_dest) if transcript: (out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8") meta = _load_meta(wav_dest) meta["enabled"] = True meta["loudness"] = loudness _save_meta(wav_dest, meta) return {"voice_id": final_id, "loudness": loudness} @router.get("/api/download-yt") async def download_yt(url: str): url = _validate_http_url(url, allow_private=_ALLOW_PRIVATE_DOWNLOADS) out_path = TEMP_DIR / f"{uuid.uuid4().hex}.%(ext)s" async def event_stream(): cmd = [ "yt-dlp", "--extract-audio", "--audio-format", "wav", "--audio-quality", "0", "--output", str(out_path), "--no-playlist", "--progress", "--newline", url, ] proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) downloaded_path: Path | None = None assert proc.stdout is not None async for raw in proc.stdout: line = raw.decode(errors="replace").rstrip() if not line: continue if "Destination:" in line and "[ExtractAudio]" not in line: m = re.search(r"Destination:\s+(.+)$", line) if m: downloaded_path = Path(m.group(1).strip()) if line.startswith("[download]") or line.startswith("[ExtractAudio]"): pct_m = re.search(r"(\d+\.\d+)%", line) pct = pct_m.group(1) if pct_m else None yield f"data: {json.dumps({'msg': line, 'pct': pct})}\n\n" await proc.wait() if downloaded_path is None or not downloaded_path.exists(): matches = glob.glob(str(out_path).replace("%(ext)s", "*")) if matches: downloaded_path = Path(matches[0]) if downloaded_path is None or not downloaded_path.exists(): yield f"data: {json.dumps({'error': 'Download failed — no output file found'})}\n\n" return try: downloaded_path.resolve().relative_to(TEMP_DIR.resolve()) except ValueError: yield f"data: {json.dumps({'error': 'Download wrote outside temp directory'})}\n\n" return try: wav = _to_wav_24k(downloaded_path) except Exception as e: yield f"data: {json.dumps({'error': f'Conversion failed: {e}'})}\n\n" return fid = uuid.uuid4().hex _registry_put(fid, wav) yield f"data: {json.dumps({'done': True, 'id': fid, 'duration': _duration(wav)})}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) @router.get("/api/elevenlabs/voices") async def elevenlabs_shared_voices(request: Request): settings = _load_settings() api_key = (settings.get("elevenlabs_api_key") or "").strip() _allowed = {"page_size", "page", "language", "gender", "age", "accent", "use_case", "category", "search", "featured"} params: dict = {k: v for k, v in request.query_params.items() if k in _allowed} params.setdefault("page_size", "24") headers = {"xi-api-key": api_key} if api_key else {} try: r = requests.get( "https://api.elevenlabs.io/v1/shared-voices", params=params, headers=headers, timeout=15, ) return r.json() except requests.exceptions.RequestException as exc: raise HTTPException(502, f"ElevenLabs API error: {exc}") # ── Fish.audio public voice library (api.fish.audio/model) ──────────────────── _FISHAUDIO_API = "https://api.fish.audio/model" def _fishaudio_voice(item: dict) -> dict: """Normalize a fish.audio model record into a voice card with a clonable MP3 sample.""" samples = item.get("samples") or [] sample = samples[0] if samples else {} langs = item.get("languages") or [] tags = item.get("tags") or [] gender = next((t for t in tags if t.lower() in ("male", "female", "neutral")), "") age = next((t for t in tags if t.lower() in ("young", "middle-aged", "middle_aged", "old")), "") author = item.get("author") cover = item.get("cover_image") return { "id": item.get("_id", ""), "title": _plain_text(item.get("title", ""))[:120], "description": _plain_text(item.get("description", ""))[:400], "tags": tags[:12], "language": (langs[0] if langs else "").upper(), "gender": gender.capitalize(), "age": age.replace("_", "-").replace("middle-aged", "Middle-aged").capitalize() if age else "", "plays": item.get("task_count", 0), "likes": item.get("like_count", 0), "author": author.get("nickname", "") if isinstance(author, dict) else (author or ""), "image": f"https://public-platform.r2.fish.audio/{cover}" if cover else "", "sample_audio": sample.get("audio", ""), # direct .mp3 URL (public, no auth) "sample_text": _plain_text(sample.get("text", ""))[:600], "default_text": _plain_text(item.get("default_text", ""))[:600], } @router.get("/api/fishaudio/voices") async def fishaudio_voices(request: Request): """Browse the public fish.audio voice library. Returns clonable MP3 samples + transcripts.""" qp = request.query_params # fish.audio only accepts these sort keys — clamp so a stale value never 422s. sort_by = qp.get("sort_by", "score") if sort_by not in ("score", "task_count", "created_at"): sort_by = "score" params: dict = { "page_size": qp.get("page_size", "24"), "page_number": qp.get("page", "1"), "sort_by": sort_by, } if qp.get("search"): params["title"] = qp["search"] if qp.get("language"): params["language"] = qp["language"] # gender / age / any number of use-case & quality tags all map to fish.audio's # repeatable `tag` filter tags = [t for t in (list(qp.getlist("tag")) + [qp.get("gender"), qp.get("age")]) if t] if tags: params["tag"] = tags import hashlib cache_dir = CONFIG_DIR / "fishaudio_cache" cache_key = hashlib.md5(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest() cache_file = cache_dir / f"{cache_key}.json" try: r = await asyncio.to_thread( lambda: requests.get(_FISHAUDIO_API, params=params, headers={"Accept": "application/json"}, timeout=15) ) r.raise_for_status() data = r.json() except requests.exceptions.RequestException as exc: # Offline fallback — serve a previously-cached copy of this query if we have one. if cache_file.is_file(): cached = json.loads(cache_file.read_text(encoding="utf-8")) cached["offline"] = True return cached raise HTTPException(502, f"fish.audio API error: {exc}") items = [_fishaudio_voice(it) for it in data.get("items", []) if it.get("type") == "tts"] result = { "items": items, "total": data.get("total", len(items)), "page": int(params["page_number"]), "page_size": int(params["page_size"]), } # Cache the normalized response so the browser keeps working if fish.audio goes offline. try: cache_dir.mkdir(parents=True, exist_ok=True) cache_file.write_text(json.dumps(result), encoding="utf-8") except Exception: pass return result # ── Web script fetch (IMSDb + generic screenplay URLs) ──────────────────────── _SCRAPE_HEADERS = { "User-Agent": ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", } def _imsdb_title_from_url(raw_url: str) -> str: """Extract the movie title from any IMSDb URL variant (script page or detail page).""" from urllib.parse import unquote as _unq path = _unq(urlparse(raw_url).path) # Direct script URL: /scripts/Some-Title.html → "Some Title" m = re.match(r"^/scripts/(.+?)\.html?$", path, re.I) if m: return m.group(1).replace("-", " ").strip() # Movie Scripts detail page: /Movie Scripts/Some Title[-Script].html m2 = re.match(r"^/Movie[\s_]Scripts/(.+?)(?:[\s-]Script)?\.html?$", path, re.I) if m2: return m2.group(1).strip().rstrip("-").strip() return "" def _imsdb_read_link(detail_html: str) -> str: """Pull the real '/scripts/Title.html' target out of a detail page's 'Read … Script' anchor. IMSDb occasionally emits a broken href like '/scripts/.' (its template chokes on titles containing '?'); requiring a '.html' suffix rejects those so we fall back to slugs. """ m = re.search(r']+href="(/scripts/[^"]+\.html)"[^>]*>\s*Read', detail_html, re.I) return m.group(1).strip() if m else "" def _imsdb_script_candidates(title: str) -> list[str]: """Slug guesses for /scripts/{slug}.html — IMSDb's slugs are inconsistent so we try a few.""" base = re.sub(r"\s+", "-", title.strip()) variants = [ base, # spaces → dashes, punctuation kept (e.g. "1492:-Conquest…") re.sub(r"[?!*]", "", base), # drop chars IMSDb strips (e.g. "Who-Framed-Roger-Rabbit") re.sub(r"[?!*:,'\"]", "", base), # drop most punctuation (e.g. "Star-Wars-A-New-Hope") ] out, seen = [], set() for slug in variants: if slug and slug not in seen: seen.add(slug) out.append(f"https://www.imsdb.com/scripts/{slug}.html") return out def _looks_like_script(html: str) -> bool: return bool(re.search(r" 2500 def _fetch_imsdb_script(raw_url: str, headers: dict) -> tuple[requests.Response, str]: """Resolve any IMSDb URL to its real screenplay page and fetch it. Strategy mirrors the site's own navigation: all-scripts → detail page → 'Read Script' link. Falls back to slug guesses when the detail link is missing or broken. Returns (response, title). """ title = _imsdb_title_from_url(raw_url) candidates: list[str] = [] # 1. If we were handed a direct /scripts/ URL, try it as-is first. if re.match(r"^/scripts/", urlparse(raw_url).path, re.I): candidates.append(f"https://www.imsdb.com{urlparse(raw_url).path}") # 2. Resolve via the detail page's authoritative "Read Script" link. if title: detail_url = f"https://www.imsdb.com/Movie Scripts/{title} Script.html" try: dresp = requests.get(detail_url, headers=headers, timeout=25) if dresp.ok: link = _imsdb_read_link(dresp.text) if link: candidates.append(urljoin("https://www.imsdb.com/", link)) except requests.exceptions.RequestException: pass # 3. Slug guesses as a fallback for broken/missing detail links. candidates.extend(_imsdb_script_candidates(title)) if not candidates: candidates.append(raw_url) last: requests.Response | None = None for url in dict.fromkeys(candidates): # dedupe, preserve order try: resp = requests.get(url, headers=headers, timeout=25, allow_redirects=True) except requests.exceptions.RequestException: continue last = resp if resp.ok and _looks_like_script(resp.text): return resp, title if last is None: raise HTTPException(502, "Could not reach IMSDb for that script") return last, title def _extract_screenplay_text(html: str) -> str: """Extract and clean screenplay text from a page's
 block."""
    m = re.search(r"]*>([\s\S]+?)
", html, re.I) if not m: return "" raw = m.group(1) # Preserve text inside and (character names / stage dirs), strip others raw = re.sub(r"(.*?)", r"\1", raw, flags=re.DOTALL | re.I) raw = re.sub(r"(.*?)", r"\1", raw, flags=re.DOTALL | re.I) raw = re.sub(r"<[^>]+>", "", raw) text = unescape(raw).replace("\r\n", "\n").replace("\r", "\n").strip() # Collapse runs of 4+ blank lines to 2 text = re.sub(r"\n{4,}", "\n\n\n", text) return text @router.post("/api/fetch-web-script") async def fetch_web_script(request: Request): """Fetch a screenplay from a web URL (IMSDb and plain text/HTML supported).""" data = await request.json() raw_url: str = (data.get("url") or "").strip() if not raw_url: raise HTTPException(400, "No URL provided") # Validate URL parsed = urlparse(raw_url) if parsed.scheme not in ("http", "https") or not parsed.netloc: raise HTTPException(400, "Invalid URL — must start with http:// or https://") host = parsed.netloc.lower().replace("www.", "") title = "" if "imsdb.com" in host: headers = {**_SCRAPE_HEADERS, "Referer": "https://www.imsdb.com/"} try: resp, title = await asyncio.to_thread(_fetch_imsdb_script, raw_url, headers) resp.raise_for_status() except HTTPException: raise except requests.exceptions.RequestException as e: raise HTTPException(502, f"Fetch failed: {e}") fetch_url = resp.url else: fetch_url = raw_url headers = _SCRAPE_HEADERS try: resp = await asyncio.to_thread( lambda: requests.get(fetch_url, headers=headers, timeout=25, allow_redirects=True) ) resp.raise_for_status() except requests.exceptions.RequestException as e: raise HTTPException(502, f"Fetch failed: {e}") content_type = resp.headers.get("content-type", "").lower() if "text/plain" in content_type: text = resp.text.strip() else: # HTML — try to extract
 screenplay block
        text = _extract_screenplay_text(resp.text)
        if not text:
            raise HTTPException(422, "Could not find screenplay text on that page. Try pasting the script manually.")

    if len(text) < 200:
        raise HTTPException(422, "The page loaded but the script appears empty or too short.")
    # (continues below)
    return _web_script_result(text, title, fetch_url, parsed)


def _web_script_result(text, title, fetch_url, parsed):
    if not title:
        title = parsed.path.split("/")[-1].replace(".html", "").replace("-", " ").replace("_", " ").strip()
    return {"text": text, "title": title, "url": fetch_url, "chars": len(text)}


# ── IMSDb library browser ─────────────────────────────────────────────────────

_IMSDB_LIST_CACHE: dict = {"items": None, "ts": 0.0}
_POSTER_CACHE: dict = {}  # title → poster url (or "" if none found)


@router.get("/api/imsdb/list")
async def imsdb_list():
    """Return the full IMSDb script catalogue: [{title, fetch_url}]. Cached 6 h in-memory."""
    import time as _t
    now = _t.time()
    if _IMSDB_LIST_CACHE["items"] and (now - _IMSDB_LIST_CACHE["ts"] < 6 * 3600):
        return {"items": _IMSDB_LIST_CACHE["items"], "cached": True}
    try:
        resp = await asyncio.to_thread(
            lambda: requests.get("https://imsdb.com/all-scripts.html", headers=_SCRAPE_HEADERS, timeout=25)
        )
        resp.raise_for_status()
    except requests.exceptions.RequestException as e:
        raise HTTPException(502, f"Could not reach IMSDb: {e}")

    items = []
    seen = set()
    for href, title in re.findall(r']*>([^<]+)', resp.text):
        title = unescape(title).strip()
        if not title or title in seen:
            continue
        seen.add(title)
        # Hand back the detail-page URL; the fetch route follows its real "Read Script"
        # link (IMSDb's per-script slugs are inconsistent, so a guessed slug often 404s).
        items.append({"title": title, "fetch_url": f"https://www.imsdb.com{href}"})
    items.sort(key=lambda x: x["title"].lower())
    _IMSDB_LIST_CACHE["items"] = items
    _IMSDB_LIST_CACHE["ts"] = now
    return {"items": items, "cached": False}


@router.get("/api/movie-poster")
async def movie_poster(title: str = ""):
    """Look up a movie poster + year via OMDb. Cached per title in-memory."""
    title = (title or "").strip()
    if not title:
        return {"poster": "", "year": ""}
    if title in _POSTER_CACHE:
        return _POSTER_CACHE[title]
    from urllib.parse import quote as _q
    # Strip trailing year/qualifiers that hurt matching
    clean = re.sub(r"\s*\(.*?\)\s*$", "", title).strip()
    result = {"poster": "", "year": "", "title": title}
    try:
        url = f"http://www.omdbapi.com/?t={_q(clean)}&type=movie&apikey=trilogy"
        r = await asyncio.to_thread(lambda: requests.get(url, timeout=8))
        if r.ok:
            d = r.json()
            poster = d.get("Poster", "")
            if poster and poster != "N/A":
                result["poster"] = poster
            result["year"] = d.get("Year", "") or ""
    except Exception:
        pass
    _POSTER_CACHE[title] = result
    return result