feat(magazine): find and import issues from the Internet Archive and OPDS catalogues

Also reuses the existing magFmtClock for the contents list (the duplicate
tripped the frontend collision gate).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-29 17:35:08 +02:00
parent 834146de30
commit c3c5c55f17
13 changed files with 879 additions and 12 deletions

View File

@ -9,6 +9,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Fixed ### Fixed
- **Voice benchmark failures now say why.** Designed voices are benchmarked through the Voice Design engine; when that service is down the row showed a bare "-" and the raw "Max retries exceeded" text. The error now names the service and the container to start, the Factor and WPM columns show `ERR` beside the time column, and a batch stops after three identical "unreachable" failures instead of retrying every remaining voice. - **Voice benchmark failures now say why.** Designed voices are benchmarked through the Voice Design engine; when that service is down the row showed a bare "-" and the raw "Max retries exceeded" text. The error now names the service and the container to start, the Factor and WPM columns show `ERR` beside the time column, and a batch stops after three identical "unreachable" failures instead of retrying every remaining voice.
### Added ### Added
- **Find an issue online (magazines).** A search panel in the magazine library finds issues in the Internet Archive and in any OPDS catalogue you configure (Calibre-Web, Kavita, Komga), shows the source's licence, and imports the PDF straight into a new magazine project, then starts ingestion. SSRF-safe (downloads only from the provider's own host), size/time bounded, feeds with DTDs refused. See `architecture/magazine-sources.md`.
- **Magazine contents list shows synthesis state.** Each article row says "Synthesised · m:ss" (the MP3's exact length) or "Not synthesised" (approved, no MP3 yet), and has an inline play/pause button that streams the MP3 without opening the article. Icon plus words, screen-reader labelled, works at phone width. - **Magazine contents list shows synthesis state.** Each article row says "Synthesised · m:ss" (the MP3's exact length) or "Not synthesised" (approved, no MP3 yet), and has an inline play/pause button that streams the MP3 without opening the article. Icon plus words, screen-reader labelled, works at phone width.
- `scripts/compare_magazines.py`: read-only comparison of two magazine folders (words, boxes, reading order, articles, quality numbers) for re-import checks. - `scripts/compare_magazines.py`: read-only comparison of two magazine folders (words, boxes, reading order, articles, quality numbers) for re-import checks.

View File

@ -338,6 +338,35 @@ Open an article and expand **Pronunciation & spelling** under the player.
**Mobile / accessibility:** the lists are ordinary buttons with labels ("Replace X with Y"); marks are underlined, not colour-only. **Mobile / accessibility:** the lists are ordinary buttons with labels ("Replace X with Y"); marks are underlined, not colour-only.
**Privacy:** the agent sends only the flagged word and its surrounding sentence to the LLM you configured in Settings; without an LLM nothing leaves the machine. **Privacy:** the agent sends only the flagged word and its surrounding sentence to the LLM you configured in Settings; without an LLM nothing leaves the machine.
### Find an issue online
**Purpose.** Pull a magazine issue into the app without leaving for a browser and uploading by hand.
**Prerequisites.** Internet access for the Internet Archive; for your own library, an OPDS catalogue
(Calibre-Web, Kavita or Komga all serve one) added under `magazine_opds_catalogs` in the app settings as
`[{"name": "NAS", "url": "http://nas.local:8083/opds/"}]`. Catalogues that need a login are not supported yet.
**Steps.**
1. Open Magazines. In *Find an issue online* pick a source, type a title or subject, press Search.
2. Read the licence line on each result. The source states it, the app does not judge it.
3. Press *Import* on a result. The PDF downloads (progress is announced under the search box), the magazine
opens and ingestion starts, exactly as after an upload.
**Expected result.** A new magazine project with the PDF stored locally, then the normal review-before-narration flow.
**Errors.** A source that is down, rate-limiting or returns something other than a PDF gives a plain message
under the search box and nothing is created that you must clean up beyond the empty project shown in your library.
Lending-only Internet Archive items are never listed. Only one download runs at a time; a PDF larger than
400 MB is refused.
**Mobile / accessibility.** The form stacks on a phone; results are a list with labelled Import buttons; status
text is a polite live region, so a screen reader hears "N results" and download progress.
**Offline.** Search and import need the network; everything after import works offline.
**Privacy.** Your search words go to the chosen source (archive.org, or your own catalogue). Nothing else is
sent. Websites that redistribute commercial magazines without a licence are deliberately not supported.
### Quality check / run (whole magazine) ### Quality check / run (whole magazine)
**Purpose:** find every page where text is not inside a box, boxes overlap or cut off letters, or OCR still looks wrong. **Purpose:** find every page where text is not inside a box, boxes overlap or cut off letters, or OCR still looks wrong.

View File

@ -745,7 +745,7 @@ casting. The original PDF remains the visual source of truth while approved arti
### P2 — Finding magazines to read ### P2 — Finding magazines to read
- [ ] **Browse and search online PDF magazine sources from inside the app**, so an issue can be found and pulled in - [~] **Browse and search online PDF magazine sources from inside the app** *(done 2026-09-29: Internet Archive + OPDS catalogues, see architecture/magazine-sources.md; still open: publisher free issues/feeds, open-access journals, OPDS login and pagination)*, so an issue can be found and pulled in
without leaving for a browser and uploading by hand. Wanted: a catalogue of sources, a search across them, and without leaving for a browser and uploading by hand. Wanted: a catalogue of sources, a search across them, and
a one-click "ingest this issue". a one-click "ingest this issue".
**Blocked on a source decision, and that decision is the whole task.** The two sites named as examples **Blocked on a source decision, and that decision is the whole task.** The two sites named as examples

View File

@ -0,0 +1,38 @@
# Magazine sources (find and import an issue)
Status: implemented (core/magazine_sources.py, routes/magazine_sources.py, magazine.js "Find an issue online").
## Goal
Search legitimate catalogues from inside the app and import an issue as a normal magazine project. Ingestion
stays a separate, existing step, so nothing is narrated before the review.
## Providers (one interface: `search`, `resolve_pdf`, `allowed_host`)
| Provider | Kind | Notes |
|---|---|---|
| Internet Archive | fixed | documented advanced-search + metadata API; loan-only items excluded (`-access-restricted-item`); prefers the original PDF over derived text PDFs |
| OPDS catalogue | user-configured (`magazine_opds_catalogs` setting) | standard Atom feed; search via the catalogue's own OpenSearch template; only PDF acquisition links |
Deliberately unsupported: sites that redistribute commercial titles without the publisher's licence (see TODO.md).
Candidates later: publisher free issues / feeds, DOAJ/arXiv/PMC. Each would be another provider.
## API
- `GET /api/magazine-sources` -> `{sources:[{key,label,kind}]}`
- `GET /api/magazine-sources/{key}/search?q=&page=` -> `{items:[{id,title,author,date,summary,license,page_url,thumb_url}]}`; 404 unknown source, 502 with a safe message when the source fails
- `POST /api/magazine-sources/import {source,item_id,title}` -> `{id,job_id}`; the download runs as a background job (`magazine-source-import`, poll `/api/jobs/{id}`); 409 while another import runs
## Security boundaries
- SSRF: a download is fetched only from the provider's own host (archive.org, or the catalogue's host). Item ids are validated (IA identifier regex; OPDS id must be same-host). Redirects are followed manually and re-checked.
- XML: feeds with a DOCTYPE or entity declaration are refused (billion-laughs / XXE class); feed size capped at 4 MB.
- Limits: 400 MB per PDF, connect/read timeouts, one import at a time, `%PDF` magic checked on the first chunk, temp file removed on failure.
- No credentials are stored or sent; catalogue URLs with embedded credentials are rejected.
- Remote text is rendered as text in the UI (escaped), links open with `noopener noreferrer`.
- Rate limits/terms: the Internet Archive asks for a descriptive User-Agent and modest request rates; searches are user-initiated, one page of 20 per request.
## Error behaviour
Every provider failure is a `SourceError` with a user-safe message (unreachable, HTTP status, rate-limited, not a PDF, too large, different host).
## Offline / sync
Needs the network only for search and download. No synchronisation state.
## Known limitations
OPDS catalogues requiring login; OPDS pagination beyond the first feed page; no thumbnails shown yet (returned in the API).

View File

@ -46,6 +46,8 @@ _SETTINGS_KEYS = {
"magazine_layout_url", "magazine_layout_model", "magazine_layout_url", "magazine_layout_model",
"vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled", "vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled",
"vision_ocr_fallback_url", "vision_ocr_fallback_model", "vision_ocr_fallback_url", "vision_ocr_fallback_model",
# OPDS catalogues to search for magazine issues: [{"name": ..., "url": ...}], no credentials.
"magazine_opds_catalogs",
"engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards", "engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards",
# Character portrait generation (cloud APIs + local ComfyUI) # Character portrait generation (cloud APIs + local ComfyUI)
"image_gen_provider", "image_gen_model", "image_gen_provider", "image_gen_model",
@ -276,6 +278,8 @@ def _normalize_settings(s: dict) -> dict:
s["engine_api_keys"] = {} s["engine_api_keys"] = {}
if not isinstance(s.get("custom_engine_cards"), list): if not isinstance(s.get("custom_engine_cards"), list):
s["custom_engine_cards"] = [] s["custom_engine_cards"] = []
if not isinstance(s.get("magazine_opds_catalogs"), list):
s["magazine_opds_catalogs"] = []
return s return s
@ -348,6 +352,7 @@ def _load_settings() -> dict:
"engine_container_names": {}, "engine_container_names": {},
"engine_api_keys": {}, "engine_api_keys": {},
"custom_engine_cards": [], "custom_engine_cards": [],
"magazine_opds_catalogs": [],
"image_gen_provider": "", "image_gen_provider": "",
"image_gen_model": "", "image_gen_model": "",
"comfyui_url": "http://host.docker.internal:8188", "comfyui_url": "http://host.docker.internal:8188",

338
core/magazine_sources.py Normal file
View File

@ -0,0 +1,338 @@
"""Legitimate places to find a magazine issue to import: OPDS catalogues and the Internet Archive.
Each source is a provider behind the same small interface (search + resolve a PDF), so one going away
or changing its API does not break the others. Nothing here scrapes a website: OPDS is the open
catalogue standard (Calibre-Web, Kavita and Komga serve it) and the Internet Archive has a documented
search and metadata API. See TODO.md ("Finding magazines to read") for why commercial redistribution
sites are deliberately not supported.
Security boundaries:
* a download is only ever fetched from the provider's own host (the OPDS catalogue's host, or
archive.org), never from a URL an arbitrary feed entry names -- a feed cannot aim the server at
another machine (SSRF);
* redirects are followed by hand and re-checked against the same host rule;
* size and time are bounded, and the result must really be a PDF.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, asdict
from typing import Iterable, Protocol
from urllib.parse import urljoin, urlsplit, quote
import requests
import xml.etree.ElementTree as ET
USER_AGENT = "tts-voice-creator-magazine-import/1.0 (self-hosted; contact via repository)"
SEARCH_TIMEOUT = (5, 20) # connect, read (seconds)
DOWNLOAD_TIMEOUT = (5, 60)
MAX_DOWNLOAD_BYTES = 400 * 1024 * 1024
MAX_FEED_BYTES = 4 * 1024 * 1024
MAX_REDIRECTS = 4
PAGE_SIZE = 20
_ATOM = "{http://www.w3.org/2005/Atom}"
_OPDS_ACQ = "http://opds-spec.org/acquisition"
_OPDS_IMAGE = ("http://opds-spec.org/image", "http://opds-spec.org/image/thumbnail")
_IA_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
class SourceError(Exception):
"""A source could not be searched or downloaded; the message is safe to show the user."""
@dataclass
class SourceItem:
id: str # provider-specific handle, e.g. the Internet Archive identifier or an OPDS entry URL
title: str
summary: str = ""
author: str = ""
date: str = ""
license: str = "" # a licence URL or name when the source states one, else empty (unknown)
page_url: str = "" # human-readable page for the item
thumb_url: str = ""
downloadable: bool = True
def as_dict(self) -> dict:
return asdict(self)
class Provider(Protocol):
key: str
label: str
def search(self, query: str, page: int = 1) -> list[SourceItem]: ...
def resolve_pdf(self, item_id: str) -> str: ...
def allowed_host(self, url: str) -> bool: ...
# ── helpers ──────────────────────────────────────────────────────────────────
def _clip(value, limit: int) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()[:limit]
def _get(session: requests.Session, url: str, *, timeout, stream: bool = False, host_ok=None,
accept: str = "*/*") -> requests.Response:
"""GET with manual, host-checked redirects."""
for _ in range(MAX_REDIRECTS + 1):
if host_ok is not None and not host_ok(url):
raise SourceError("The source pointed to a different host; refusing to follow it")
try:
resp = session.get(url, timeout=timeout, stream=stream, allow_redirects=False,
headers={"User-Agent": USER_AGENT, "Accept": accept})
except requests.RequestException as e:
raise SourceError(f"Could not reach the source ({type(e).__name__})") from e
if resp.is_redirect or resp.status_code in (301, 302, 303, 307, 308):
target = resp.headers.get("location") or ""
resp.close()
if not target:
raise SourceError("The source sent an empty redirect")
url = urljoin(url, target)
continue
if resp.status_code == 429:
resp.close()
raise SourceError("The source is rate-limiting requests; try again in a minute")
if resp.status_code >= 400:
resp.close()
raise SourceError(f"The source answered HTTP {resp.status_code}")
return resp
raise SourceError("Too many redirects")
def _read_limited(resp: requests.Response, limit: int) -> bytes:
out = bytearray()
for chunk in resp.iter_content(64 * 1024):
out.extend(chunk)
if len(out) > limit:
resp.close()
raise SourceError("The response is larger than the allowed size")
return bytes(out)
def parse_xml_safely(payload: bytes) -> ET.Element:
"""Parse a feed while refusing DTDs and entity declarations (billion-laughs / XXE class)."""
head = payload[:4096].lower()
if b"<!doctype" in head or b"<!entity" in payload[:65536].lower():
raise SourceError("The feed uses an XML feature that is not allowed")
try:
return ET.fromstring(payload)
except ET.ParseError as e:
raise SourceError("The feed is not valid XML") from e
def download_pdf(provider: Provider, item_id: str, dest, *, session: requests.Session | None = None,
progress=None) -> int:
"""Download the item's PDF to `dest` (a pathlib.Path); returns the byte count.
Written to a temporary sibling first and moved into place only once it is verified to be a PDF.
"""
from pathlib import Path
dest = Path(dest)
session = session or requests.Session()
url = provider.resolve_pdf(item_id)
resp = _get(session, url, timeout=DOWNLOAD_TIMEOUT, stream=True, host_ok=provider.allowed_host,
accept="application/pdf,*/*")
tmp = dest.with_suffix(".part")
total = 0
try:
declared = int(resp.headers.get("content-length") or 0)
if declared > MAX_DOWNLOAD_BYTES:
raise SourceError("This PDF is larger than the allowed size")
with open(tmp, "wb") as fh:
first = True
for chunk in resp.iter_content(256 * 1024):
if not chunk:
continue
if first:
if not chunk.lstrip()[:5].startswith(b"%PDF"):
raise SourceError("The source did not return a PDF")
first = False
total += len(chunk)
if total > MAX_DOWNLOAD_BYTES:
raise SourceError("This PDF is larger than the allowed size")
fh.write(chunk)
if progress:
progress(total, declared)
if total == 0:
raise SourceError("The source returned an empty file")
tmp.replace(dest)
return total
except BaseException:
tmp.unlink(missing_ok=True)
raise
finally:
resp.close()
# ── Internet Archive ─────────────────────────────────────────────────────────
class InternetArchiveProvider:
"""archive.org: documented advanced-search and metadata APIs; only freely downloadable items are listed."""
key = "internet-archive"
label = "Internet Archive"
_HOST = "archive.org"
def __init__(self, session: requests.Session | None = None):
self._s = session or requests.Session()
def allowed_host(self, url: str) -> bool:
parts = urlsplit(url)
host = (parts.hostname or "").lower()
return parts.scheme == "https" and (host == self._HOST or host.endswith("." + self._HOST))
def search(self, query: str, page: int = 1) -> list[SourceItem]:
q = _clip(query, 200)
if not q:
raise SourceError("Enter something to search for")
# Lucene-escape quotes only; the rest is passed as the user's free text.
text = q.replace('"', " ")
# Loan-only scans are not downloadable, and a PDF is what this app ingests.
lucene = f'({text}) AND mediatype:texts AND format:pdf AND -access-restricted-item:true AND (subject:magazine OR collection:magazine_rack OR collection:periodicals)'
params = {
"q": lucene, "fl[]": ["identifier", "title", "description", "creator", "date", "licenseurl"],
"rows": PAGE_SIZE, "page": max(1, int(page)), "output": "json",
}
try:
resp = self._s.get(f"https://{self._HOST}/advancedsearch.php", params=params, timeout=SEARCH_TIMEOUT,
headers={"User-Agent": USER_AGENT})
except requests.RequestException as e:
raise SourceError(f"Could not reach the Internet Archive ({type(e).__name__})") from e
if resp.status_code == 429:
raise SourceError("The Internet Archive is rate-limiting requests; try again in a minute")
if resp.status_code != 200:
raise SourceError(f"The Internet Archive answered HTTP {resp.status_code}")
try:
docs = resp.json()["response"]["docs"]
except (ValueError, KeyError, TypeError) as e:
raise SourceError("The Internet Archive returned an unexpected answer") from e
return list(self._items(docs))
@staticmethod
def _first(v) -> str:
return _clip(v[0] if isinstance(v, list) and v else v, 500)
def _items(self, docs: Iterable[dict]) -> Iterable[SourceItem]:
for d in docs or []:
ident = self._first(d.get("identifier"))
if not _IA_ID.fullmatch(ident):
continue
yield SourceItem(
id=ident, title=self._first(d.get("title")) or ident,
summary=_clip(self._first(d.get("description")), 400), author=self._first(d.get("creator")),
date=self._first(d.get("date"))[:10], license=self._first(d.get("licenseurl")),
page_url=f"https://{self._HOST}/details/{quote(ident)}",
thumb_url=f"https://{self._HOST}/services/img/{quote(ident)}",
)
def resolve_pdf(self, item_id: str) -> str:
if not _IA_ID.fullmatch(item_id or ""):
raise SourceError("Not a valid Internet Archive identifier")
resp = _get(self._s, f"https://{self._HOST}/metadata/{quote(item_id)}", timeout=SEARCH_TIMEOUT,
host_ok=self.allowed_host, accept="application/json")
try:
meta = resp.json()
except ValueError as e:
raise SourceError("The Internet Archive returned an unexpected answer") from e
finally:
resp.close()
if (meta.get("metadata") or {}).get("access-restricted-item") in ("true", True):
raise SourceError("This item is lending-only and cannot be downloaded")
files = [f for f in meta.get("files") or [] if str(f.get("name", "")).lower().endswith(".pdf")]
# The original upload beats a derived "Text PDF"; among equals the largest is the full issue.
def rank(f):
return (0 if f.get("source") == "original" else 1, -int(f.get("size") or 0))
files.sort(key=rank)
if not files:
raise SourceError("This item has no PDF file")
return f"https://{self._HOST}/download/{quote(item_id)}/{quote(str(files[0]['name']))}"
# ── OPDS ─────────────────────────────────────────────────────────────────────
class OpdsProvider:
"""A user-configured OPDS catalogue (their own Calibre-Web/Kavita/Komga, or any public one)."""
def __init__(self, key: str, label: str, root_url: str, session: requests.Session | None = None):
self.key = key
self.label = label
self._root = root_url
self._host = (urlsplit(root_url).hostname or "").lower()
self._s = session or requests.Session()
def allowed_host(self, url: str) -> bool:
parts = urlsplit(url)
return parts.scheme in ("http", "https") and (parts.hostname or "").lower() == self._host
def _feed(self, url: str) -> ET.Element:
resp = _get(self._s, url, timeout=SEARCH_TIMEOUT, stream=True, host_ok=self.allowed_host,
accept="application/atom+xml,application/xml,text/xml,*/*")
try:
return parse_xml_safely(_read_limited(resp, MAX_FEED_BYTES))
finally:
resp.close()
def _search_url(self) -> str:
"""The catalogue's own OpenSearch template if it advertises one, else the conventional path."""
root = self._feed(self._root)
for link in root.findall(f"{_ATOM}link"):
if link.get("rel") == "search" and link.get("type", "").startswith("application/atom+xml"):
href = urljoin(self._root, link.get("href", ""))
if "{searchTerms}" in href:
return href
return urljoin(self._root, "search?query={searchTerms}")
def search(self, query: str, page: int = 1) -> list[SourceItem]:
q = _clip(query, 200)
if not q:
raise SourceError("Enter something to search for")
url = self._search_url().replace("{searchTerms}", quote(q))
items = list(self._entries(self._feed(url), url))
start = (max(1, int(page)) - 1) * PAGE_SIZE
return items[start:start + PAGE_SIZE]
def _entries(self, root: ET.Element, base: str) -> Iterable[SourceItem]:
for e in root.findall(f"{_ATOM}entry"):
pdf = ""
thumb = ""
page_url = ""
for link in e.findall(f"{_ATOM}link"):
href = urljoin(base, link.get("href", ""))
rel = link.get("rel", "")
if rel.startswith(_OPDS_ACQ) and "pdf" in (link.get("type") or "").lower() and not pdf:
pdf = href
elif rel in _OPDS_IMAGE and not thumb:
thumb = href
elif rel == "alternate" and not page_url:
page_url = href
# An entry with no PDF is a navigation entry or an EPUB-only book; not importable here.
if not pdf or not self.allowed_host(pdf):
continue
author = e.find(f"{_ATOM}author/{_ATOM}name")
yield SourceItem(
id=pdf, title=_clip(e.findtext(f"{_ATOM}title"), 300) or "Untitled",
summary=_clip(e.findtext(f"{_ATOM}summary") or e.findtext(f"{_ATOM}content"), 400),
author=_clip(author.text if author is not None else "", 200),
date=_clip(e.findtext(f"{_ATOM}updated"), 10),
page_url=page_url if self.allowed_host(page_url or "") else "",
thumb_url=thumb if self.allowed_host(thumb or "") else "",
)
def resolve_pdf(self, item_id: str) -> str:
# The id *is* the acquisition URL the search returned; accept only this catalogue's host.
if not self.allowed_host(item_id or ""):
raise SourceError("That item does not belong to this catalogue")
return item_id
def build_providers(catalogs: list[dict] | None, session: requests.Session | None = None) -> dict[str, Provider]:
"""The Internet Archive plus every configured OPDS catalogue, keyed for the API."""
providers: dict[str, Provider] = {InternetArchiveProvider.key: InternetArchiveProvider(session)}
for n, cat in enumerate(catalogs or [], 1):
url = str((cat or {}).get("url") or "").strip()
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.hostname or parts.username or parts.password:
continue
key = f"opds-{n}"
providers[key] = OpdsProvider(key, _clip((cat or {}).get("name"), 80) or parts.hostname, url, session)
return providers

110
routes/magazine_sources.py Normal file
View File

@ -0,0 +1,110 @@
"""Find a magazine issue in a legitimate source and import it as a new magazine project.
Search and download go through core.magazine_sources (Internet Archive + configured OPDS catalogues).
An import only creates the project and stores source.pdf; ingestion stays the existing, separate step so
the user still reviews before anything is narrated.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
import uuid
from fastapi import APIRouter, HTTPException, Request
from core import magazine_sources as ms
from core.config import _load_settings
from core.constants import CONFIG_DIR
from core.database import background_job_create, background_job_update
from routes.magazine import _write_meta
router = APIRouter()
_LIB = CONFIG_DIR / "reader_library"
_import_lock = asyncio.Lock() # one download at a time: polite to the source and to the disk
_KEY = re.compile(r"[a-z0-9-]{1,40}")
def _providers() -> dict:
return ms.build_providers(_load_settings().get("magazine_opds_catalogs") or [])
def _provider(key: str):
provider = _providers().get(key if _KEY.fullmatch(key or "") else "")
if provider is None:
raise HTTPException(404, "Unknown source")
return provider
@router.get("/api/magazine-sources")
async def magazine_sources_list():
return {"sources": [{"key": p.key, "label": p.label, "kind": "opds" if p.key.startswith("opds-") else "archive"}
for p in _providers().values()]}
@router.get("/api/magazine-sources/{key}/search")
async def magazine_sources_search(key: str, q: str = "", page: int = 1):
provider = _provider(key)
try:
items = await asyncio.to_thread(provider.search, q, min(max(page, 1), 50))
except ms.SourceError as e:
raise HTTPException(502, str(e))
return {"source": key, "items": [i.as_dict() for i in items]}
def _download_sync(doc_id: str, job_id: str, key: str, item_id: str) -> None:
dest = _LIB / doc_id / "source.pdf"
try:
provider = _provider(key)
def progress(done: int, total: int) -> None:
mb = done / 1e6
detail = f"Downloaded {mb:.0f} of {total / 1e6:.0f} MB" if total else f"Downloaded {mb:.0f} MB"
background_job_update(job_id, stage="download", detail=detail)
background_job_update(job_id, stage="download", detail="Contacting the source")
size = ms.download_pdf(provider, item_id, dest, progress=progress)
background_job_update(job_id, stage="done", detail="Downloaded", done=True,
result={"doc_id": doc_id, "bytes": size})
except ms.SourceError as e:
background_job_update(job_id, stage="error", detail="Download failed", done=True, error=str(e)[:300])
except HTTPException as e:
background_job_update(job_id, stage="error", detail="Download failed", done=True, error=str(e.detail)[:300])
except Exception:
logging.exception("Magazine source download failed")
background_job_update(job_id, stage="error", detail="Download failed", done=True,
error="The download failed unexpectedly")
@router.post("/api/magazine-sources/import")
async def magazine_sources_import(request: Request):
body = await request.json()
key = str(body.get("source") or "")
item_id = str(body.get("item_id") or "")
provider = _provider(key)
if not item_id or len(item_id) > 2000:
raise HTTPException(400, "item_id is required")
if _import_lock.locked():
raise HTTPException(409, "Another import is still downloading")
title = re.sub(r"\s+", " ", str(body.get("title") or "")).strip()[:300] or "Untitled magazine"
doc_id = uuid.uuid4().hex
d = _LIB / doc_id
d.mkdir(parents=True, exist_ok=True)
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
_write_meta(d, {
"kind": "magazine", "title": title, "issue": str(body.get("issue") or "").strip()[:120],
"publisher": "", "created": now, "updated": now, "ingest": {"status": "empty"},
"default_voice": "", "podcast": {"hosts": []}, "progress": {},
"source": {"provider": provider.label, "item": item_id[:500]},
})
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-source-import", fingerprint=doc_id)
async def run():
async with _import_lock:
await asyncio.to_thread(_download_sync, doc_id, job_id, key, item_id)
asyncio.create_task(run())
return {"id": doc_id, "job_id": job_id}

View File

@ -19,7 +19,7 @@ from starlette.datastructures import MutableHeaders
from core.constants import STATIC_DIR, _BufferHandler, _MAX_JSON_BYTES, _MAX_UPLOAD_BYTES, _MAX_RAW_REQUEST_BYTES, _REQUIRE_EXTERNAL_API_KEY from core.constants import STATIC_DIR, _BufferHandler, _MAX_JSON_BYTES, _MAX_UPLOAD_BYTES, _MAX_RAW_REQUEST_BYTES, _REQUIRE_EXTERNAL_API_KEY
from core.config import _load_settings, _ensure_external_api_key from core.config import _load_settings, _ensure_external_api_key
from core.voice_index import refresh_voice_index_background from core.voice_index import refresh_voice_index_background
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history, jobs, magazine from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history, jobs, magazine, magazine_sources
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@ -347,6 +347,7 @@ app.include_router(recovery.router)
app.include_router(history.router) app.include_router(history.router)
app.include_router(jobs.router) app.include_router(jobs.router)
app.include_router(magazine.router) app.include_router(magazine.router)
app.include_router(magazine_sources.router)
# ── Static files ────────────────────────────────────────────────────────────── # ── Static files ──────────────────────────────────────────────────────────────

View File

@ -88,15 +88,6 @@ function fmtDuration(seconds) {
return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min'; return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min';
} }
// Exact clock time (m:ss, h:mm:ss) -- the estimate above is deliberately coarse, this one names a real MP3.
function fmtClock(seconds) {
const s = Math.max(0, Math.round(seconds || 0));
const ss = String(s % 60).padStart(2, '0');
return s >= 3600
? Math.floor(s / 3600) + ':' + String(Math.floor(s / 60) % 60).padStart(2, '0') + ':' + ss
: Math.floor(s / 60) + ':' + ss;
}
// The contents list doubles as the playlist: one shared <audio> for the inline play buttons, // The contents list doubles as the playlist: one shared <audio> for the inline play buttons,
// separate from the reading view's own playback so neither disturbs the other. // separate from the reading view's own playback so neither disturbs the other.
const tocPlayer = { audio: null, id: null }; const tocPlayer = { audio: null, id: null };
@ -189,6 +180,7 @@ async function magLoadLibrary() {
const data = await api('/api/reader/docs'); const data = await api('/api/reader/docs');
state.docs = (data.docs || []).filter((d) => d.kind === 'magazine'); state.docs = (data.docs || []).filter((d) => d.kind === 'magazine');
renderLibrary(); renderLibrary();
magFindLoadSources();
} }
function renderLibrary() { function renderLibrary() {
@ -443,6 +435,86 @@ async function magCreate(droppedFile = null) {
} }
} }
// ── Find an issue online ─────────────────────────────────────────────────
async function magFindLoadSources() {
const sel = $('mag-find-source');
if (!sel || sel.dataset.loaded) return;
try {
const { sources } = await api('/api/magazine-sources');
sel.innerHTML = sources.map((s) => '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>').join('');
sel.dataset.loaded = '1';
} catch (e) {
const st = $('mag-find-status');
if (st) st.textContent = 'Could not load the sources: ' + e.message;
}
}
async function magFindSearch(ev) {
if (ev) ev.preventDefault();
const q = ($('mag-find-query')?.value || '').trim();
const source = $('mag-find-source')?.value;
const status = $('mag-find-status'), list = $('mag-find-results');
if (!q) { status.textContent = 'Enter something to search for.'; return; }
status.textContent = 'Searching…';
list.innerHTML = '';
try {
const data = await api('/api/magazine-sources/' + encodeURIComponent(source) + '/search?q=' + encodeURIComponent(q));
magFindRender(source, data.items || []);
} catch (e) {
status.textContent = 'Search failed: ' + e.message;
}
}
function magFindRender(source, items) {
const status = $('mag-find-status'), list = $('mag-find-results');
state.findItems = items;
status.textContent = items.length ? items.length + ' result' + (items.length === 1 ? '' : 's') + '.' : 'Nothing found.';
const isUrl = (u) => /^https?:\/\//.test(u || '');
list.innerHTML = items.map((it, i) =>
'<li class="mag-find-item">' +
'<div class="mag-find-main"><strong>' + esc(it.title) + '</strong>' +
'<span class="muted">' + [it.author, it.date].filter(Boolean).map(esc).join(' · ') + '</span>' +
(it.summary ? '<span class="mag-find-summary">' + esc(it.summary) + '</span>' : '') +
'<span class="muted">' + (it.license
? 'Licence: ' + (isUrl(it.license) ? '<a href="' + esc(it.license) + '" target="_blank" rel="noopener noreferrer">' + esc(it.license) + '</a>' : esc(it.license))
: 'Licence not stated by the source') +
(isUrl(it.page_url) ? ' · <a href="' + esc(it.page_url) + '" target="_blank" rel="noopener noreferrer">Open source page</a>' : '') +
'</span></div>' +
'<button type="button" class="btn-secondary btn-sm" aria-label="Import ' + esc(it.title) + '" ' +
'onclick="magFindImport(\'' + esc(source) + '\', ' + i + ', this)"><span class="mdi mdi-download" aria-hidden="true"></span> Import</button>' +
'</li>').join('');
}
async function magFindImport(source, index, btn) {
const it = (state.findItems || [])[index];
if (!it) return;
const status = $('mag-find-status');
btn.disabled = true;
try {
status.textContent = 'Starting download of “' + it.title + '”…';
const { id, job_id } = await api('/api/magazine-sources/import', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, item_id: it.id, title: it.title }),
});
for (;;) {
await new Promise((r) => setTimeout(r, 1500));
const job = await api('/api/jobs/' + encodeURIComponent(job_id));
if (job.error) throw new Error(job.error);
if (job.done) break;
status.textContent = job.detail || 'Downloading…';
}
status.textContent = 'Downloaded — starting ingestion.';
await magOpen(id);
await magIngest();
} catch (e) {
status.textContent = 'Import failed: ' + e.message;
say('Import failed: ' + e.message, 'err');
} finally {
btn.disabled = false;
}
}
// ── Ingestion ──────────────────────────────────────────────────────────────── // ── Ingestion ────────────────────────────────────────────────────────────────
// Remembers which magazine was open so a full page reload or the browser's // Remembers which magazine was open so a full page reload or the browser's
@ -781,7 +853,7 @@ function renderContents() {
// Marked with an icon and words, never colour alone. // Marked with an icon and words, never colour alone.
const synthHtml = (a) => a.has_audio const synthHtml = (a) => a.has_audio
? '<span class="mag-synth is-done" title="MP3 rendered"><span class="mdi mdi-waveform" aria-hidden="true"></span> Synthesised' + ? '<span class="mag-synth is-done" title="MP3 rendered"><span class="mdi mdi-waveform" aria-hidden="true"></span> Synthesised' +
(a.audio_seconds ? ' · ' + fmtClock(a.audio_seconds) : '') + '</span>' (a.audio_seconds ? ' · ' + magFmtClock(a.audio_seconds) : '') + '</span>'
: a.status === 'approved' : a.status === 'approved'
? '<span class="mag-synth" title="Approved, no MP3 yet"><span class="mdi mdi-microphone-off" aria-hidden="true"></span> Not synthesised</span>' ? '<span class="mag-synth" title="Approved, no MP3 yet"><span class="mdi mdi-microphone-off" aria-hidden="true"></span> Not synthesised</span>'
: ''; : '';
@ -5212,6 +5284,8 @@ root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove; root.magApprove = magApprove;
root.magSetStatus = magSetStatus; root.magSetStatus = magSetStatus;
root.magRead = magRead; root.magRead = magRead;
root.magFindSearch = magFindSearch;
root.magFindImport = magFindImport;
root.magTocPlay = magTocPlay; root.magTocPlay = magTocPlay;
root.magSeek = magSeek; root.magSeek = magSeek;
root.magPlay = magPlay; root.magPlay = magPlay;

View File

@ -46,6 +46,22 @@
</div> </div>
</div> </div>
<div class="card mag-find">
<h3>Find an issue online</h3>
<p class="note">Search the Internet Archive and any OPDS catalogue you have set up (Calibre-Web, Kavita, Komga). Only freely downloadable PDFs are listed; check the licence shown before you use one.</p>
<form class="mag-find-form" id="mag-find-form" onsubmit="magFindSearch(event)" role="search">
<label for="mag-find-source">Source
<select id="mag-find-source"></select>
</label>
<label for="mag-find-query" class="mag-find-query">Search
<input id="mag-find-query" type="search" maxlength="200" autocomplete="off" placeholder="e.g. Scientific American 1923">
</label>
<button type="submit" class="btn-primary btn-sm" id="mag-find-btn"><span class="mdi mdi-magnify" aria-hidden="true"></span> Search</button>
</form>
<p class="note" id="mag-find-status" role="status" aria-live="polite"></p>
<ul class="mag-find-results" id="mag-find-results"></ul>
</div>
<h3>Your magazines</h3> <h3>Your magazines</h3>
<div id="mag-library-list" class="mag-grid"></div> <div id="mag-library-list" class="mag-grid"></div>
</div> </div>

View File

@ -5546,3 +5546,15 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-spell-edit:hover, .mag-spell-edit:focus-visible { background: rgba(217,48,37,.14); outline: 2px solid rgba(217,48,37,.5); outline-offset: 1px; } .mag-spell-edit:hover, .mag-spell-edit:focus-visible { background: rgba(217,48,37,.14); outline: 2px solid rgba(217,48,37,.5); outline-offset: 1px; }
.mag-spell-editor { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 1 1 320px; } .mag-spell-editor { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 1 1 320px; }
.mag-spell-input { flex: 1 1 200px; min-width: 140px; font: inherit; padding: 4px 8px; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--text); } .mag-spell-input { flex: 1 1 200px; min-width: 140px; font: inherit; padding: 4px 8px; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--text); }
/* Find an issue online (magazine library) */
.mag-find { margin-bottom: 18px; }
.mag-find-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
.mag-find-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.mag-find-query { flex: 1 1 240px; }
.mag-find-results { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 8px; }
.mag-find-item { display: flex; gap: 12px; align-items: center; justify-content: space-between;
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.mag-find-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mag-find-summary { color: var(--subtext); font-size: 13px; overflow-wrap: anywhere; }
@media (max-width: 600px) { .mag-find-item { flex-direction: column; align-items: stretch; } }

View File

@ -1368,3 +1368,35 @@ test('the contents list marks an approved article without audio as not synthesis
await expect(row).toContainText('Not synthesised'); await expect(row).toContainText('Not synthesised');
await expect(row.getByRole('button', { name: /^Play / })).toHaveCount(0); await expect(row.getByRole('button', { name: /^Play / })).toHaveCount(0);
}); });
test('finding an issue online: search, licence shown, import starts ingestion', async ({ page }) => {
await mockBackend(page);
const imported = [];
await page.route('**/api/magazine-sources', route => route.fulfill({
json: { sources: [{ key: 'internet-archive', label: 'Internet Archive', kind: 'archive' }] } }));
await page.route('**/api/magazine-sources/internet-archive/search*', route => route.fulfill({
json: { source: 'internet-archive', items: [
{ id: 'sci-am-1923', title: 'Scientific American 1923', author: 'Munn', date: '1923-01-01', summary: 'Vol 128',
license: 'https://creativecommons.org/publicdomain/mark/1.0/', page_url: 'https://archive.org/details/sci-am-1923' },
{ id: 'other', title: '<img src=x onerror=alert(1)>', license: '' }] } }));
await page.route('**/api/magazine-sources/import', route => {
imported.push(route.request().postDataJSON());
route.fulfill({ json: { id: DOC_ID, job_id: 'job1' } });
});
await page.route('**/api/jobs/job1', route => route.fulfill({ json: { done: true, error: null, detail: 'Downloaded' } }));
await page.route(`**/api/magazine/${DOC_ID}/ingest`, route => route.fulfill({ json: { job_id: 'ing1' } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await expect(page.locator('#mag-find-source option')).toHaveCount(1);
await page.locator('#mag-find-query').fill('scientific american');
await page.getByRole('button', { name: 'Search', exact: true }).click();
await expect(page.locator('#mag-find-status')).toContainText('2 results');
await expect(page.locator('.mag-find-item').first()).toContainText('publicdomain');
await expect(page.locator('.mag-find-item').nth(1)).toContainText('Licence not stated');
// Titles from a remote source are text, never markup.
await expect(page.locator('.mag-find-item img')).toHaveCount(0);
await page.getByRole('button', { name: 'Import Scientific American 1923' }).click();
await expect.poll(() => imported.length).toBe(1);
expect(imported[0]).toMatchObject({ source: 'internet-archive', item_id: 'sci-am-1923' });
});

View File

@ -0,0 +1,211 @@
"""Magazine sources: parsing, host restriction, size/type checks. No network."""
import os
import tempfile
import unittest
from pathlib import Path
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-"))
from core import magazine_sources as ms
class FakeResp:
def __init__(self, status=200, body=b"", headers=None, json_data=None):
self.status_code = status
self._body = body
self.headers = headers or {}
self._json = json_data
self.is_redirect = status in (301, 302, 303, 307, 308)
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
def iter_content(self, n):
for i in range(0, len(self._body), n):
yield self._body[i:i + n]
def close(self):
pass
class FakeSession:
def __init__(self, routes):
self.routes = routes
self.calls = []
def get(self, url, **kw):
self.calls.append(url)
for prefix, resp in self.routes.items():
if url.startswith(prefix):
return resp() if callable(resp) else resp
return FakeResp(404)
OPDS = b"""<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title>c't 12/2026</title><author><name>Heise</name></author><summary>Issue</summary>
<link rel="http://opds-spec.org/acquisition" type="application/pdf" href="/dl/1.pdf"/>
<link rel="http://opds-spec.org/image/thumbnail" href="/thumb/1.jpg"/></entry>
<entry><title>Elsewhere</title>
<link rel="http://opds-spec.org/acquisition" type="application/pdf" href="http://evil.example/x.pdf"/></entry>
<entry><title>EPUB only</title>
<link rel="http://opds-spec.org/acquisition" type="application/epub+zip" href="/dl/2.epub"/></entry>
</feed>"""
class OpdsTests(unittest.TestCase):
def provider(self, routes):
return ms.OpdsProvider("opds-1", "NAS", "http://nas.local:8083/opds/", FakeSession(routes))
def test_search_keeps_only_same_host_pdf_entries(self):
# Most specific route first: the fake session matches by prefix.
p = self.provider({"http://nas.local:8083/opds/search": FakeResp(body=OPDS),
"http://nas.local:8083/opds/": FakeResp(body=b'<feed xmlns="http://www.w3.org/2005/Atom"/>')})
items = p.search("ct")
self.assertEqual([i.title for i in items], ["c't 12/2026"])
self.assertEqual(items[0].id, "http://nas.local:8083/dl/1.pdf")
self.assertEqual(items[0].thumb_url, "http://nas.local:8083/thumb/1.jpg")
def test_resolve_rejects_other_hosts(self):
p = self.provider({})
with self.assertRaises(ms.SourceError):
p.resolve_pdf("http://169.254.169.254/latest/meta-data")
def test_doctype_and_entities_are_refused(self):
evil = b'<?xml version="1.0"?><!DOCTYPE lol [<!ENTITY a "aaaa">]><feed xmlns="http://www.w3.org/2005/Atom"/>'
with self.assertRaises(ms.SourceError):
ms.parse_xml_safely(evil)
def test_malformed_feed_is_a_source_error(self):
with self.assertRaises(ms.SourceError):
ms.parse_xml_safely(b"<feed")
def test_empty_query_rejected(self):
with self.assertRaises(ms.SourceError):
self.provider({}).search(" ")
class ArchiveTests(unittest.TestCase):
def test_search_builds_items_and_skips_bad_identifiers(self):
docs = [{"identifier": "ct-2026-01", "title": "c't", "creator": ["Heise"], "date": "2026-01-01T00:00:00Z"},
{"identifier": "../../etc/passwd", "title": "x"}]
s = FakeSession({"https://archive.org/advancedsearch.php": FakeResp(json_data={"response": {"docs": docs}})})
items = ms.InternetArchiveProvider(s).search("ct")
self.assertEqual([i.id for i in items], ["ct-2026-01"])
self.assertEqual(items[0].date, "2026-01-01")
self.assertEqual(items[0].author, "Heise")
def test_resolve_prefers_original_pdf(self):
meta = {"metadata": {}, "files": [
{"name": "a_text.pdf", "source": "derivative", "size": "9999"},
{"name": "issue.pdf", "source": "original", "size": "100"}]}
s = FakeSession({"https://archive.org/metadata/": FakeResp(json_data=meta)})
url = ms.InternetArchiveProvider(s).resolve_pdf("ct-2026-01")
self.assertEqual(url, "https://archive.org/download/ct-2026-01/issue.pdf")
def test_lending_only_is_refused(self):
s = FakeSession({"https://archive.org/metadata/": FakeResp(
json_data={"metadata": {"access-restricted-item": "true"}, "files": []})})
with self.assertRaises(ms.SourceError):
ms.InternetArchiveProvider(s).resolve_pdf("loan")
def test_rate_limit_message(self):
s = FakeSession({"https://archive.org/advancedsearch.php": FakeResp(status=429)})
with self.assertRaisesRegex(ms.SourceError, "rate-limiting"):
ms.InternetArchiveProvider(s).search("ct")
def test_bad_identifier_refused(self):
with self.assertRaises(ms.SourceError):
ms.InternetArchiveProvider(FakeSession({})).resolve_pdf("a/b")
class DownloadTests(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-dl-"))
self.p = ms.OpdsProvider("opds-1", "NAS", "http://nas.local/opds/", None)
def test_downloads_pdf(self):
s = FakeSession({"http://nas.local/dl": FakeResp(body=b"%PDF-1.7 hello")})
n = ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s)
self.assertEqual(n, 14)
self.assertEqual((self.dir / "source.pdf").read_bytes()[:5], b"%PDF-")
self.assertFalse((self.dir / "source.part").exists())
def test_rejects_non_pdf_and_cleans_up(self):
s = FakeSession({"http://nas.local/dl": FakeResp(body=b"<html>login</html>")})
with self.assertRaisesRegex(ms.SourceError, "PDF"):
ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s)
self.assertEqual(list(self.dir.iterdir()), [])
def test_redirect_to_other_host_is_refused(self):
s = FakeSession({"http://nas.local/dl": FakeResp(status=302, headers={"location": "http://evil.example/x"})})
with self.assertRaisesRegex(ms.SourceError, "different host"):
ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s)
def test_oversize_declared_is_refused(self):
s = FakeSession({"http://nas.local/dl": FakeResp(body=b"%PDF-", headers={"content-length": str(ms.MAX_DOWNLOAD_BYTES + 1)})})
with self.assertRaisesRegex(ms.SourceError, "larger"):
ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s)
def test_http_error(self):
s = FakeSession({"http://nas.local/dl": FakeResp(status=403)})
with self.assertRaisesRegex(ms.SourceError, "403"):
ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s)
class BuildProvidersTests(unittest.TestCase):
def test_skips_bad_and_credentialed_catalogues(self):
provs = ms.build_providers([
{"name": "NAS", "url": "http://nas.local/opds"},
{"name": "bad", "url": "ftp://x"},
{"name": "creds", "url": "http://user:pw@nas.local/opds"},
{"url": ""}])
self.assertEqual(sorted(provs), ["internet-archive", "opds-1"])
if __name__ == "__main__":
unittest.main()
class RouteTests(unittest.TestCase):
"""The HTTP layer over the providers, on a minimal app (no auth middleware, no network)."""
def setUp(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest import mock
from routes import magazine_sources as routes
self.routes = routes
self.app = FastAPI()
self.app.include_router(routes.router)
self.client = TestClient(self.app)
self.mock = mock
def test_lists_archive_and_configured_catalogues(self):
with self.mock.patch.object(self.routes, "_load_settings",
return_value={"magazine_opds_catalogs": [{"name": "NAS", "url": "http://nas.local/opds"}]}):
data = self.client.get("/api/magazine-sources").json()
self.assertEqual([s["key"] for s in data["sources"]], ["internet-archive", "opds-1"])
def test_unknown_source_is_404(self):
with self.mock.patch.object(self.routes, "_load_settings", return_value={}):
self.assertEqual(self.client.get("/api/magazine-sources/nope/search?q=x").status_code, 404)
self.assertEqual(self.client.get("/api/magazine-sources/..%2f/search?q=x").status_code, 404)
def test_source_errors_become_502_with_a_message(self):
class Boom:
key = "internet-archive"
label = "x"
def search(self, q, page=1):
raise ms.SourceError("The source is rate-limiting requests; try again in a minute")
with self.mock.patch.object(self.routes, "_providers", return_value={"internet-archive": Boom()}):
r = self.client.get("/api/magazine-sources/internet-archive/search?q=x")
self.assertEqual(r.status_code, 502)
self.assertIn("rate-limiting", r.json()["detail"])
def test_import_requires_an_item(self):
with self.mock.patch.object(self.routes, "_load_settings", return_value={}):
r = self.client.post("/api/magazine-sources/import", json={"source": "internet-archive"})
self.assertEqual(r.status_code, 400)