"""LibriVox fetcher — public-domain audiobook recordings. JSON API: https://librivox.org/api/feed/audiobooks (keyless). LibriVox is public domain by charter; the reader/author is credited in notes but attribution is not required. Section-level (per-track) file resolution is a future refinement; this resolves the audiobook's zip as the media target. """ from __future__ import annotations import re from tools.ingest.base import Candidate from tools.licensing import librivox_license BASE = "https://librivox.org/api/feed/audiobooks" def _slug(text: str) -> str: s = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") return s or "untitled" def _ext_from_url(url: str, default: str) -> str: tail = url.rsplit("/", 1)[-1] return tail.rsplit(".", 1)[-1].lower() if "." in tail else default class LibriVoxFetcher: archive = "librivox" def __init__(self, client): self.client = client def _candidate(self, book) -> Candidate: title = book.get("title", "") authors = book.get("authors") or [] credit = ", ".join( f"{a.get('first_name', '')} {a.get('last_name', '')}".strip() for a in authors ).strip(", ") media_url = book.get("url_zip_file", "") description = (book.get("description", "") or "").strip() if credit: description = (description + f" (author: {credit})").strip() lic, attr = librivox_license() return Candidate( source_archive=self.archive, source_url=book.get("url_librivox", ""), media_url=media_url, title=title, license=lic, attribution=attr, suggested_id=f"librivox-{_slug(title)}", media_ext=_ext_from_url(media_url, "zip"), description=description, ) def search(self, query: str, *, limit: int) -> list[Candidate]: url = f"{BASE}/title/^{query}?format=json&limit={limit}" data = self.client.get_json(url) books = (data.get("books") or [])[:limit] return [self._candidate(b) for b in books] def resolve(self, identifier: str) -> Candidate: url = f"{BASE}/id/{identifier}?format=json" data = self.client.get_json(url) books = data.get("books") or [] if not books: raise ValueError(f"librivox: no audiobook for {identifier!r}") return self._candidate(books[0])