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:
parent
834146de30
commit
c3c5c55f17
@ -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.
|
||||||
|
|
||||||
|
|||||||
29
Manual.md
29
Manual.md
@ -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.
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@ -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
|
||||||
|
|||||||
38
architecture/magazine-sources.md
Normal file
38
architecture/magazine-sources.md
Normal 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).
|
||||||
@ -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
338
core/magazine_sources.py
Normal 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
110
routes/magazine_sources.py
Normal 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}
|
||||||
@ -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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; } }
|
||||||
|
|||||||
@ -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' });
|
||||||
|
});
|
||||||
|
|||||||
211
tests/test_magazine_sources.py
Normal file
211
tests/test_magazine_sources.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user