feat: LibriVox/NASA/Internet Archive fetchers (+ deferred stubs)
Per sub-project-2 plan Task 9 / spec §6.4. Three keyless first-ship fetchers parse documented JSON APIs via an injected HttpClient; license/attribution go through tools.licensing; NASA third-party + IA no-license cases are flagged in notes for review. musopen/fma/freesound are explicit deferred stubs raising NotImplementedError; freesound documents FREESOUND_API_TOKEN (secret, env-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""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])
|
||||
Reference in New Issue
Block a user