tts-voice-creator-clone-and.../core/open_voice_sources.py
mARTin-B78 772c43d7f0 feat(voices): LibriVox and Wikimedia Commons voice sources
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:42:36 +02:00

197 lines
8.4 KiB
Python

"""LibriVox and Wikimedia Commons voice search (official APIs only, fixed hosts). See architecture/voice-sources.md."""
from __future__ import annotations
import html
import re
import threading
import time
from urllib.parse import urlsplit
import requests
from core.provenance import validate_provenance
LIBRIVOX_API = "https://librivox.org/api/feed/audiobooks"
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
USER_AGENT = "TTSVoiceCreator/1.0 (self-hosted voice library; official-API client)" # no personal data
MAX_Q = 80
MAX_RESULTS = 10
MAX_BYTES = 8 * 1024 * 1024
TIMEOUT = 30 # the LibriVox extended feed takes ~14 s
CACHE_TTL = 600
CACHE_MAX = 200
MIN_INTERVAL = 1.0 # seconds between upstream calls per source (MediaWiki asks for serial requests)
# host suffixes allowed in result URLs (checked on every URL taken from an API response)
AUDIO_HOSTS = ("archive.org", "upload.wikimedia.org")
PAGE_HOSTS = ("librivox.org", "archive.org", "commons.wikimedia.org")
SOURCES = ("librivox", "wikimedia")
LANG_NAMES = {
"en": "English", "de": "German", "fr": "French", "es": "Spanish", "it": "Italian", "pt": "Portuguese",
"nl": "Dutch", "pl": "Polish", "ru": "Russian", "ja": "Japanese", "zh": "Chinese", "sv": "Swedish",
}
_CTRL = re.compile(r"[\x00-\x1f\x7f]")
_TAGS = re.compile(r"<[^>]*>")
_cache: dict[tuple, tuple[float, list]] = {}
_last_call: dict[str, float] = {}
_locks = {s: threading.Lock() for s in SOURCES}
_cache_lock = threading.Lock()
class SourceError(Exception):
"""Bad input (400) or upstream failure (502); message is safe to show."""
def __init__(self, msg: str, status: int = 400):
super().__init__(msg)
self.status = status
def host_ok(url: str, suffixes: tuple[str, ...]) -> bool:
try:
p = urlsplit(str(url))
h = (p.hostname or "").lower()
except ValueError:
return False
return p.scheme == "https" and not p.username and not p.password and any(h == s or h.endswith("." + s) for s in suffixes)
def clean_query(q: str) -> str:
q = _CTRL.sub(" ", str(q or "")).strip()
q = re.sub(r"\s+", " ", q)
if not q:
raise SourceError("q is required")
if len(q) > MAX_Q:
raise SourceError(f"q is too long (max {MAX_Q})")
return q
def clean_lang(lang: str) -> str:
lang = str(lang or "").strip().lower()
if lang and not re.fullmatch(r"[a-z]{2,3}", lang):
raise SourceError("lang must be a 2-3 letter code")
return lang
def _text(value, limit=200) -> str:
return _CTRL.sub(" ", html.unescape(_TAGS.sub("", str(value or "")))).strip()[:limit]
def _get_json(url: str, params: dict) -> dict:
wait = 0.0
src = "librivox" if url == LIBRIVOX_API else "wikimedia"
with _locks[src]:
wait = MIN_INTERVAL - (time.monotonic() - _last_call.get(src, 0.0))
if wait > 0:
time.sleep(wait)
_last_call[src] = time.monotonic()
try:
r = requests.get(url, params=params, headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
timeout=TIMEOUT, allow_redirects=False, stream=True)
if r.status_code == 404 and src == "librivox":
return {}
r.raise_for_status()
body = b""
for chunk in r.iter_content(65536):
body += chunk
if len(body) > MAX_BYTES:
raise SourceError("Upstream response too large", 502)
import json
return json.loads(body.decode("utf-8", "replace"))
except SourceError:
raise
except Exception:
raise SourceError(f"{src} is unavailable right now", 502)
def _librivox_items(q: str, lang: str) -> list[dict]:
data = _get_json(LIBRIVOX_API, {"title": "^" + q, "format": "json", "extended": 1, "limit": 15}) # "^" = prefix match; bare title is exact
want = LANG_NAMES.get(lang, "").lower()
out = []
for b in (data.get("books") or []) if isinstance(data, dict) else []:
if not isinstance(b, dict):
continue
if want and str(b.get("language") or "").lower() != want:
continue
secs = [s for s in (b.get("sections") or []) if isinstance(s, dict) and host_ok(s.get("listen_url") or "", AUDIO_HOSTS)]
src_url = b.get("url_librivox") or ""
if not secs or not host_ok(src_url, PAGE_HOSTS):
continue
s = secs[0]
readers = ", ".join(_text(r.get("display_name"), 60) for r in (s.get("readers") or []) if isinstance(r, dict))
authors = ", ".join(_text(f"{a.get('first_name', '')} {a.get('last_name', '')}", 60) for a in (b.get("authors") or []) if isinstance(a, dict))
try:
dur = int(s.get("playtime") or 0)
except (TypeError, ValueError):
dur = 0
out.append({
"source": "librivox", "title": _text(f"{b.get('title')} - {s.get('title')}"), "reader": readers, "author": authors,
"language": _text(b.get("language"), 40), "duration": dur,
"licence": "Public domain (LibriVox, USA)", "licence_url": "https://librivox.org/pages/public-domain/",
"source_url": src_url, "audio_url": s["listen_url"], "attribution": "",
})
if len(out) >= MAX_RESULTS:
break
return out
def _wikimedia_items(q: str) -> list[dict]:
data = _get_json(COMMONS_API, {
"action": "query", "format": "json", "generator": "search", "gsrnamespace": 6, "gsrlimit": 20,
"gsrsearch": f"filetype:audio {q}", "prop": "imageinfo", "iiprop": "url|extmetadata|mediatype", "maxlag": 5,
})
pages = ((data.get("query") or {}).get("pages") or {}) if isinstance(data, dict) else {}
out = []
for p in sorted((x for x in pages.values() if isinstance(x, dict)), key=lambda x: x.get("index", 0)):
info = (p.get("imageinfo") or [{}])[0]
meta = info.get("extmetadata") or {}
mv = lambda k: (meta.get(k) or {}).get("value", "")
lic = _text(mv("LicenseShortName"), 80)
audio, page = info.get("url") or "", info.get("descriptionurl") or ""
if info.get("mediatype") != "AUDIO" or not lic or not host_ok(audio, AUDIO_HOSTS) or not host_ok(page, PAGE_HOSTS):
continue
licurl = mv("LicenseUrl") if host_ok(mv("LicenseUrl"), ("creativecommons.org", "wikimedia.org")) else ""
title = _text(re.sub(r"^File:|\.[A-Za-z0-9]+$", "", str(p.get("title") or "")))
artist = _text(mv("Artist"), 120)
out.append({
"source": "wikimedia", "title": title, "reader": artist, "author": artist, "language": "",
"duration": round(float(info.get("duration") or 0)), "licence": lic, "licence_url": licurl,
"source_url": page, "audio_url": audio,
"attribution": _text(f"{artist + ' / ' if artist else ''}{title}, {lic}, via Wikimedia Commons", 300),
})
if len(out) >= MAX_RESULTS:
break
return out
def search(source: str, q: str, lang: str = "") -> list[dict]:
if source not in SOURCES:
raise SourceError("unknown source")
q, lang = clean_query(q), clean_lang(lang)
key = (source, q.lower(), lang if source == "librivox" else "")
now = time.monotonic()
with _cache_lock:
hit = _cache.get(key)
if hit and now - hit[0] < CACHE_TTL:
return hit[1]
items = _librivox_items(q, lang) if source == "librivox" else _wikimedia_items(q)
with _cache_lock:
if len(_cache) >= CACHE_MAX:
_cache.pop(min(_cache, key=lambda k: _cache[k][0]))
_cache[key] = (now, items)
return items
def import_provenance_for(kind: str, source_url: str, licence: str, attribution: str) -> dict:
"""Provenance prefill for an import from one of these sources (raises ValueError on bad input)."""
if kind == "librivox":
return validate_provenance({
"basis": "public_domain", "source": source_url, "licence": "Public domain (LibriVox)",
"note": "LibriVox recordings are donated to the public domain (guaranteed in the USA only). "
"Check the reader's declared licence on the LibriVox page before use."})
if kind == "wikimedia":
pd = re.search(r"public domain|\bcc0\b|\bpd\b", licence, re.I)
return validate_provenance({
"basis": "public_domain" if pd else "licensed", "source": source_url, "licence": licence,
"note": ("Attribution: " + attribution)[:1000]})
raise ValueError("unknown source_kind")