Files
pi-agent-config/scenarios/curator/backend/curator/web.py
T
Kai 88b06d782f feat(curator): vendor the application backend as the scenario's tracked source
The curator Python backend (package, tests, systemd units, config templates, scripts) now lives under scenarios/curator/backend and is the single source of truth; the live checkout at the workspace path is a runtime copy. Exported from the app repo's tracked tree via git archive (no history, .pi/venv/caches excluded). 149 unit tests pass from the new location.

profile.toml backend is now repo-relative (scenarios/curator/backend); verify-generated.sh resolves a relative backend against REPO_ROOT. verify-no-secrets ASSIGN heuristic now requires value entropy so vendored kwargs like token=extraction_token no longer false-positive. README documents the backend/ layout and the operator-owned app rollout step.
2026-08-30 18:49:10 -07:00

968 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import hmac
import html
import json
import os
import re
import shutil
import threading
import urllib.parse
import uuid
from email.parser import BytesParser
from email.policy import default
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from .config import Settings
from .covers import CoverStore
from .db import Database
from .epub import read_member
from .library import Library
from .service import CuratorService, WriteRequest
from .manual_acquisition import book_search_url
def decode_form_field(part: Any) -> str:
payload = part.get_payload(decode=True) or b""
if isinstance(payload, str):
value = payload
else:
charset = part.get_content_charset() or "utf-8"
try:
value = payload.decode(charset)
except (LookupError, UnicodeDecodeError) as exc:
raise ValueError(f"表单字段不是有效的 {charset} 文本") from exc
value = value.strip()
if "\ufffd" in value:
raise ValueError("表单字段包含损坏字符,请重新填写")
return value
CSS = """
:root { color-scheme: light; --ink:#17191c; --muted:#626870; --line:#d9dde2;
--surface:#fff; --wash:#f4f6f7; --accent:#146c43; --danger:#a33131; }
* { box-sizing:border-box; }
body { margin:0; color:var(--ink); background:var(--wash); font:15px/1.55 system-ui,sans-serif; }
header { background:#202529; color:#fff; border-bottom:3px solid #4ea778; }
nav, main { width:min(1080px, calc(100% - 28px)); margin:auto; }
nav { min-height:56px; display:flex; align-items:center; gap:18px; }
nav strong { font-size:18px; margin-right:auto; }
nav a { color:#fff; text-decoration:none; }
main { padding:24px 0 48px; }
h1 { font-size:27px; margin:0 0 18px; letter-spacing:0; }
h2 { font-size:19px; margin:28px 0 12px; letter-spacing:0; }
.metrics { display:grid; grid-template-columns:repeat(auto-fit, minmax(130px,1fr)); gap:1px; border:1px solid var(--line); background:var(--line); }
.metric { background:var(--surface); padding:16px; min-height:84px; }
.metric b { display:block; font-size:25px; }
.metric span,.muted { color:var(--muted); }
.panel { background:var(--surface); border:1px solid var(--line); border-radius:6px; padding:18px; }
table { width:100%; border-collapse:collapse; background:var(--surface); }
th,td { text-align:left; padding:11px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
th { color:var(--muted); font-size:13px; font-weight:600; }
a { color:#0b6240; }
label { display:block; font-weight:600; margin:0 0 5px; }
input,select { width:100%; min-height:42px; border:1px solid #aeb5bc; border-radius:4px; padding:8px 10px; background:#fff; font:inherit; }
.grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.field { margin-bottom:16px; }
button,.button { display:inline-flex; align-items:center; justify-content:center; min-height:42px; border:0; border-radius:4px; padding:9px 16px; background:var(--accent); color:#fff; font-weight:650; text-decoration:none; cursor:pointer; }
.secondary { background:#525960; }
.notice { padding:12px 14px; border-left:4px solid var(--accent); background:#eaf4ef; margin-bottom:18px; }
.error { border-color:var(--danger); background:#f9eded; }
.status-success { color:#12613b; }.status-failed { color:var(--danger); }
.tag { display:inline-block; border:1px solid var(--line); border-radius:4px; padding:2px 7px; font-size:13px; background:#fff; }
.tag-pending { color:#8a4b08; border-color:#d7a35d; background:#fff8eb; }
.tag-owned,.tag-wanted,.tag-selected { color:#12613b; border-color:#8cc6a7; background:#edf8f2; }
.tag-ignored,.tag-not_recommended,.tag-superseded { color:var(--muted); background:#f1f2f3; }
.actions { display:flex; gap:8px; flex-wrap:wrap; }
.actions form { margin:0; }
.empty { color:var(--muted); padding:26px 12px; text-align:center; }
.reader { width:100%; min-height:calc(100vh - 150px); border:1px solid var(--line); background:#fff; }
.readerbar { display:flex; gap:10px; align-items:center; margin-bottom:12px; }
.readerbar span { margin-right:auto; }
@media (max-width:700px) {
nav { gap:10px; flex-wrap:wrap; padding:10px 0; } nav strong { width:100%; font-size:16px; }
.metrics { grid-template-columns:1fr 1fr; }
.grid { grid-template-columns:1fr; gap:0; }
table,.scroll { display:block; overflow-x:auto; }
th,td { min-width:120px; }
}
/* Workflow-oriented Curator UI */
:root { --ink:#202124; --muted:#687078; --line:#dfe3e6; --wash:#f5f6f4; --accent:#176b50;
--blue:#355f8a; --amber:#956113; --shadow:0 1px 2px rgba(24,32,38,.06); }
header { position:sticky; top:0; z-index:10; background:#22282b; }
nav,main { width:min(1180px,calc(100% - 32px)); }
nav { min-height:58px; gap:22px; white-space:nowrap; }
nav strong { font-size:17px; } nav a { padding:17px 0 14px; border-bottom:3px solid transparent; }
nav a:hover { border-color:#83c6a6; }
main { padding:30px 0 56px; } h1 { font-size:28px; line-height:1.2; margin:0; }
h3 { font-size:18px; line-height:1.35; margin:0; letter-spacing:0; }
.pagehead { display:flex; align-items:flex-end; justify-content:space-between; gap:20px; margin-bottom:22px; }
.pagehead p { color:var(--muted); margin:6px 0 0; }
.metrics { grid-template-columns:repeat(4,minmax(0,1fr)); background:#fff; box-shadow:var(--shadow); }
.metric { min-height:92px; padding:18px 20px; }.metric b { font-size:27px; line-height:1.2; }.metric a { color:inherit; text-decoration:none; }
.split { display:grid; grid-template-columns:minmax(0,2fr) minmax(280px,1fr); gap:24px; align-items:start; }
.sectionhead { display:flex; align-items:center; justify-content:space-between; gap:14px; margin:30px 0 12px; }.sectionhead h2 { margin:0; }
.segments { display:flex; overflow-x:auto; border-bottom:1px solid var(--line); margin-bottom:20px; }
.segments a { flex:0 0 auto; padding:9px 13px; color:var(--muted); text-decoration:none; border-bottom:3px solid transparent; }
.segments a.active { color:var(--ink); border-color:var(--accent); font-weight:700; }
.media-list { display:grid; gap:12px; }
.media-card { display:grid; grid-template-columns:112px minmax(0,1fr) 154px; gap:18px; padding:16px; background:#fff; border:1px solid var(--line); border-radius:6px; box-shadow:var(--shadow); }
.media-cover { width:112px; aspect-ratio:2/3; display:flex; flex-direction:column; justify-content:space-between; padding:12px; overflow:hidden; background:#395f52; color:#fff; }
.media-cover { position:relative; }.media-cover img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
.media-cover.movie { background:#43566b; }.media-cover.tv { background:#684c55; }.media-cover.music { background:#655b3e; }
.media-cover small { font-size:10px; font-weight:800; }.media-cover strong { font-size:15px; line-height:1.25; overflow-wrap:anywhere; }
.media-main { min-width:0; }.media-main h3 a { color:var(--ink); text-decoration:none; }.media-main h3 a:hover { color:var(--accent); }
.byline { color:var(--muted); margin:4px 0 9px; }.meta-row { display:flex; gap:7px; flex-wrap:wrap; margin-bottom:9px; }
.summary { max-width:760px; color:#343a3e; }.source-note { color:var(--muted); font-size:13px; }
.review-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; margin:10px 0; }.review-grid ul { margin:4px 0 0; padding-left:18px; }
details { margin-top:9px; } details summary { color:var(--accent); cursor:pointer; font-weight:650; }
.media-actions { width:154px; display:flex; flex-direction:column; align-items:stretch; gap:8px; }.media-actions .button,.media-actions button { width:100%; }
.button.compact,button.compact { min-height:34px; padding:6px 10px; }.secondary { background:#fff; border:1px solid #aeb5bc; color:#394046; }.quiet { background:#edf0f1; color:#394046; }
.tag { display:inline-flex; align-items:center; min-height:24px; }.tag-owned { color:#176b50; }.tag-wanted,.tag-selected { color:#355f8a; border-color:#aabfd2; background:#eff5fa; }
.media-tag { text-transform:uppercase; font-weight:750; }.empty { background:#fff; border:1px dashed #c7cdd1; }
.library-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; }
.library-item { display:grid; grid-template-columns:70px minmax(0,1fr); gap:13px; padding:14px; background:#fff; border:1px solid var(--line); border-radius:6px; }
.library-item .media-cover { width:70px; padding:8px; }.library-item .media-cover strong { font-size:11px; }.library-item h3 { font-size:16px; }.library-item p { color:var(--muted); font-size:13px; }
.source-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }.source-item { padding:17px; background:#fff; border:1px solid var(--line); border-radius:6px; }.source-item h3 a { color:var(--ink); text-decoration:none; }.source-stats { display:flex; gap:14px; color:var(--muted); margin-top:12px; font-size:13px; }
.timeline { background:#fff; border:1px solid var(--line); }.timeline-row { display:grid; grid-template-columns:94px 90px minmax(0,1fr) 170px; gap:12px; padding:11px 13px; border-bottom:1px solid var(--line); }.timeline-row:last-child { border:0; }
@media (max-width:800px) {
nav { width:100%; overflow-x:auto; gap:18px; padding:0 16px; flex-wrap:nowrap; } nav strong { position:sticky; left:0; width:auto; background:#22282b; padding-right:12px; }
main { width:min(100% - 24px,1180px); padding-top:22px; }.pagehead { align-items:flex-start; flex-direction:column; }.metrics { grid-template-columns:1fr 1fr; }
.split,.grid,.review-grid { grid-template-columns:1fr; }.media-card { grid-template-columns:78px minmax(0,1fr); gap:12px; padding:12px; }.media-cover { width:78px; padding:8px; }.media-cover strong { font-size:11px; }
.media-actions { grid-column:1/-1; width:auto; flex-direction:row; flex-wrap:wrap; }.media-actions .button,.media-actions button { width:auto; flex:1; }.library-grid { grid-template-columns:1fr; }
.source-list { grid-template-columns:1fr; }
.timeline-row { grid-template-columns:75px 75px minmax(0,1fr); }.timeline-row time { display:none; }
}
"""
def page(title: str, body: str) -> bytes:
document = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(title)} · Curator</title>
<style>{CSS}</style></head><body><header><nav><strong>Curator</strong>
<a href="/">总览</a><a href="/candidates">发现</a><a href="/wanted">待获取</a><a href="/library">资料库</a><a href="/sources">来源</a><a href="/activity">活动</a><a href="/upload">导入</a></nav></header>
<main>{body}</main></body></html>"""
return document.encode("utf-8")
class CuratorServer(ThreadingHTTPServer):
def __init__(self, address: tuple[str, int], settings: Settings, database: Database):
super().__init__(address, CuratorHandler)
self.settings = settings
self.database = database
self.library = Library(settings, database)
self.covers = CoverStore(settings, database)
self.service = CuratorService(settings, database)
class CuratorHandler(BaseHTTPRequestHandler):
server: CuratorServer
TOKEN_COOKIE = "curator_token"
def log_message(self, format: str, *args: Any) -> None:
super().log_message(format, *args)
# -- authentication -------------------------------------------------
#
# Every route except /api/health and /login requires the access token. The
# health endpoint stays open so systemd and a load balancer can probe it
# without a credential, and it reveals nothing a probe may not see.
#
# The token is either a Bearer header (curl / programmatic) or a cookie
# (browser). The cookie is HttpOnly and SameSite=Strict: Strict means a
# cross-site POST cannot carry it, which is what makes the write endpoints
# resistant to CSRF without a second token -- the only same-origin way to
# authenticate is to actually hold the token. Writes are POST-only.
def _cookie_token(self) -> str:
for chunk in (self.headers.get("Cookie") or "").split(";"):
name, _, value = chunk.partition("=")
if name.strip() == self.TOKEN_COOKIE:
return value.strip()
return ""
def authorized(self) -> bool:
token = self.server.settings.web_token
if not token:
return True
auth = self.headers.get("Authorization") or ""
if auth.startswith("Bearer ") and hmac.compare_digest(auth[len("Bearer ") :], token):
return True
cookie = self._cookie_token()
return bool(cookie) and hmac.compare_digest(cookie, token)
def _auth_required(self) -> bool:
"""Whether the given path is exempt from authentication."""
path = urllib.parse.urlparse(self.path).path
return path not in ("/api/health", "/login")
def _send_login(self, message: str = "") -> None:
notice = f'<div class="notice error">{html.escape(message)}</div>' if message else ""
body = f"""<div class="pagehead"><div><h1>登录</h1><p>Curator 需要访问令牌</p></div></div>
{notice}<form method="post" action="/login"><div class="field"><label for="token">访问令牌</label>
<input id="token" name="token" type="password" autocomplete="current-password" autofocus></div>
<div class="field"><button type="submit">进入</button></div></form>"""
self.send_bytes(page("登录", body), "text/html; charset=utf-8", 401)
def _form_fields(self) -> dict[str, str]:
"""Flat view of an application/x-www-form-urlencoded POST body."""
length = int(self.headers.get("Content-Length", "0"))
values = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
return {key: vals[0] if vals else "" for key, vals in values.items()}
def _do_login(self) -> None:
form = self._form_fields()
supplied = form.get("token", "")
token = self.server.settings.web_token
if not token or not hmac.compare_digest(supplied, token):
self._send_login("令牌不正确")
return
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header(
"Set-Cookie",
f"{self.TOKEN_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000",
)
self.send_header("Location", "/")
self.end_headers()
def send_bytes(self, data: bytes, content_type: str, status: int = 200, filename: str = "") -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("X-Content-Type-Options", "nosniff")
if filename:
quoted = urllib.parse.quote(filename)
self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quoted}")
self.end_headers()
self.wfile.write(data)
def redirect(self, path: str) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", path)
self.end_headers()
@staticmethod
def media_label(media_type: str) -> str:
return {"book": "书籍", "movie": "电影", "tv": "剧集", "music": "音乐"}.get(media_type, media_type)
@staticmethod
def short_time(value: str) -> str:
return value[:16].replace("T", " ") if value else ""
@staticmethod
def json_object(value: Any) -> dict[str, Any]:
try:
parsed = json.loads(value or "{}") if isinstance(value, str) else value
except (json.JSONDecodeError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
@staticmethod
def json_list(value: Any) -> list[Any]:
try:
parsed = json.loads(value or "[]") if isinstance(value, str) else value
except (json.JSONDecodeError, TypeError):
return []
return parsed if isinstance(parsed, list) else []
def _sandboxed(self, data: bytes, content_type: str, filename: str = "") -> None:
"""Serve untrusted content in a unique opaque origin.
Used for uploaded EPUB chapters and inline PDFs. A sandboxed document
cannot read the Curator session cookie, touch the authenticated write
endpoints, or reflect the token -- this closes P2-6's stored-XSS hole at
the response rather than trusting the iframe attribute alone.
"""
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header(
"Content-Security-Policy",
"sandbox; default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; media-src 'self'; base-uri 'none'; form-action 'none'",
)
if filename:
self.send_header("Content-Disposition", f"inline; filename*=UTF-8''{urllib.parse.quote(filename)}")
self.end_headers()
self.wfile.write(data)
def cover(self, kind: str, entity_id: int, media_type: str, title: str) -> str:
safe_type = media_type if media_type in {"book", "movie", "tv", "music"} else "book"
image = f'<img src="/cover/{kind}/{entity_id}" alt="" loading="lazy" onerror="this.remove()">'
return (f'<div class="media-cover {safe_type}">{image}<small>{html.escape(self.media_label(safe_type))}</small>'
f'<strong>{html.escape(title)}</strong></div>')
def candidate_card(self, item: Any, *, compact: bool = False, show_source: bool = True) -> str:
media_type = str(item["media_type"] or "unknown")
title = str(item["title"] or "未命名")
creator = str(item["creator"] or "").strip()
metadata = self.json_object(item["metadata_json"])
matches = self.json_list(item["library_matches_json"])
web_review = metadata.get("book_web_review") or {}
evidence = metadata.get("book_web_review_evidence") or []
verdicts = {"strong": "强烈推荐", "worth": "值得", "optional": "按兴趣", "skip": "不建议", "insufficient": "证据不足"}
recommendation = verdicts.get(str(web_review.get("verdict") or item["recommendation"]), "待判断")
summary = str(web_review.get("summary") or item["summary"] or "").strip()
byline = creator or str(item["original_title"] or "").strip()
tags = [f'<span class="tag media-tag">{html.escape(self.media_label(media_type))}</span>', self.status_tag(str(item["status"]))]
if recommendation != "待判断":
tags.append(f'<span class="tag">{html.escape(recommendation)}</span>')
for match in matches[:2]:
quality = str(match.get("quality") or "")
instance = str(match.get("instance") or "")
text = " · ".join(value for value in (quality.upper(), instance) if value)
if text:
tags.append(f'<span class="tag">{html.escape(text)}</span>')
rating_bits = []
rating_labels = {"douban": "豆瓣读书", "goodreads": "Goodreads"}
rating_scales = {"douban": 10, "goodreads": 5}
for review in metadata.get("book_reviews") or []:
if review.get("rating") is not None:
provider = str(review.get("provider") or "评分")
rating_bits.append(
f'{rating_labels.get(provider, provider)} {float(review["rating"]):.1f}/{rating_scales.get(provider, 5)}'
)
for source in evidence:
match = re.search(r"豆瓣评分[:]\s*(\d+(?:\.\d+)?)", str(source.get("snippet") or ""))
if match:
rating_bits.append(f"豆瓣 {match.group(1)}/10")
break
if rating_bits:
tags.append(f'<span class="tag">{html.escape("".join(rating_bits))}</span>')
review_detail = ""
if media_type == "book" and not compact:
strengths = [str(value) for value in web_review.get("strengths") or []][:3]
caveats = [str(value) for value in web_review.get("caveats") or []][:3]
source_links = []
for ref in (web_review.get("evidence_refs") or list(range(min(3, len(evidence)))))[:3]:
if not isinstance(ref, int) or ref < 0 or ref >= len(evidence):
continue
source = evidence[ref]
url = str(source.get("url") or "")
label = str(source.get("domain") or source.get("title") or "评价来源")
if url.startswith(("http://", "https://")):
source_links.append(f'<a href="{html.escape(url, quote=True)}" target="_blank" rel="noopener">{html.escape(label)}</a>')
columns = ""
if strengths:
columns += '<div><b>值得关注</b><ul>' + "".join(f'<li>{html.escape(value)}</li>' for value in strengths) + '</ul></div>'
if caveats:
columns += '<div><b>需要留意</b><ul>' + "".join(f'<li>{html.escape(value)}</li>' for value in caveats) + '</ul></div>'
if columns or source_links:
sources = ('<p class="source-note">评价来源:' + " · ".join(source_links) + '</p>') if source_links else ""
review_detail = f'<details><summary>评价依据</summary><div class="review-grid">{columns}</div>{sources}</details>'
actions = []
if item["status"] == "pending":
next_target = "/candidates?status=pending" if show_source else f'/source/{item["inbox_item_id"]}'
actions.extend([
f'<form method="post" action="/candidate/{item["id"]}"><input type="hidden" name="action" value="collect"><input type="hidden" name="next" value="{html.escape(next_target, quote=True)}"><button type="submit">加入待获取</button></form>',
f'<form method="post" action="/candidate/{item["id"]}"><input type="hidden" name="action" value="ignore"><input type="hidden" name="next" value="{html.escape(next_target, quote=True)}"><button class="secondary" type="submit">忽略</button></form>',
])
if media_type == "book" and item["library_state"] != "owned":
search_url = book_search_url(self.server.settings.zlib_search_url_template, title, creator)
if search_url:
actions.append(f'<a class="button secondary" href="{html.escape(search_url, quote=True)}" target="_blank" rel="noreferrer">查找 EPUB</a>')
upload_query = urllib.parse.urlencode({"title": title, "author": creator})
actions.append(f'<a class="button quiet" href="/upload?{upload_query}">导入文件</a>')
if show_source and item["inbox_item_id"]:
actions.append(f'<a class="button quiet" href="/source/{item["inbox_item_id"]}">查看来源</a>')
source_line = ""
if show_source and item["inbox_item_id"]:
source_line = f'<p class="source-note">来自 <a href="/source/{item["inbox_item_id"]}">{html.escape(item["source_title"])}</a></p>'
return f'''<article class="media-card">{self.cover("candidate", int(item["id"]), media_type, title)}<div class="media-main"><div class="meta-row">{"".join(tags)}</div>
<h3>{html.escape(title)}</h3>{f'<p class="byline">{html.escape(byline)}</p>' if byline else ''}
{f'<p class="summary">{html.escape(summary)}</p>' if summary else ''}{review_detail}{source_line}</div>
<div class="media-actions">{"".join(actions)}</div></article>'''
def do_GET(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if not self.authorized() and self._auth_required():
self._send_login()
return
try:
if path == "/":
self.dashboard(parsed.query)
elif path == "/books":
self.library(parsed.query)
elif path == "/library":
self.library(parsed.query)
elif path == "/login":
self._send_login()
elif path == "/upload":
self.upload_form(parsed.query)
elif path == "/wanted":
self.wanted()
elif path == "/sources":
self.sources()
elif path == "/activity":
self.activity()
elif path == "/candidates":
self.candidates(parsed.query)
elif path == "/api/health":
self.health()
elif path == "/api/books":
self.api_books()
elif path == "/api/sources":
self.api_sources()
elif path == "/api/candidates":
self.api_candidates(parsed.query)
elif path.startswith("/cover/"):
_, _, kind, entity_id = path.split("/", 3)
self.cover_asset(kind, int(entity_id))
elif path.startswith("/work/"):
self.work(int(path.rsplit("/", 1)[1]))
elif path.startswith("/source/"):
self.source(int(path.rsplit("/", 1)[1]))
elif path.startswith("/reader/"):
self.reader(int(path.rsplit("/", 1)[1]), parsed.query)
elif path.startswith("/epub/"):
_, _, asset_id, member = path.split("/", 3)
self.epub_member(int(asset_id), urllib.parse.unquote(member))
elif path.startswith("/download/"):
self.download(int(path.rsplit("/", 1)[1]))
else:
self.send_error(HTTPStatus.NOT_FOUND)
except (ValueError, KeyError) as exc:
self.send_bytes(page("请求错误", f'<div class="notice error">{html.escape(str(exc))}</div>'), "text/html; charset=utf-8", 400)
except Exception as exc:
self.send_bytes(page("服务错误", f'<div class="notice error">{html.escape(str(exc))}</div>'), "text/html; charset=utf-8", 500)
def do_POST(self) -> None: # noqa: N802
path = urllib.parse.urlparse(self.path).path
if not self.authorized() and self._auth_required():
self.send_response(HTTPStatus.UNAUTHORIZED)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"unauthorized")
return
try:
if path == "/login":
self._do_login()
return
if path == "/upload":
self.receive_upload()
elif path == "/wanted":
self.receive_wanted()
elif path.startswith("/candidate/"):
self.update_candidate(int(path.rsplit("/", 1)[1]))
else:
self.send_error(HTTPStatus.NOT_FOUND)
except Exception as exc:
self.send_bytes(page("导入失败", f'<div class="notice error">{html.escape(str(exc))}</div><a class="button secondary" href="/upload">返回</a>'), "text/html; charset=utf-8", 400)
def dashboard(self, query: str) -> None:
counts = self.server.database.counts()
candidates = self.server.database.all_media_candidates()
owned = [item for item in candidates if item["status"] == "owned"]
pending = [item for item in candidates if item["status"] == "pending"]
params = urllib.parse.parse_qs(query)
notice = ""
if "message" in params:
notice = f'<div class="notice">{html.escape(params["message"][0])}</div>'
recent = "".join(self.candidate_card(item, compact=True) for item in pending[:4])
recent = recent or '<div class="empty">没有等待决定的作品</div>'
jobs = self.server.database.recent_jobs(5)
activity = "".join(
f'<div class="timeline-row"><span>#{job["id"]}</span><span class="status-{html.escape(job["status"])}">{html.escape(job["status"])}</span>'
f'<span>{html.escape(job["detail"] or job["kind"])}</span><time>{html.escape(self.short_time(job["updated_at"]))}</time></div>'
for job in jobs
) or '<div class="empty">暂无活动</div>'
writable = os.access(self.server.settings.library_root, os.W_OK)
storage = "可写" if writable else "只读,导入将被阻止"
body = f"""{notice}<div class="pagehead"><div><h1>总览</h1><p>书、影、音的决策与入库状态</p></div>
<a class="button" href="/upload">导入电子书</a></div>
<div class="metrics"><div class="metric"><b><a href="/candidates?status=pending">{len(pending)}</a></b><span>待决定</span></div>
<div class="metric"><b><a href="/wanted">{counts['wanted_books']}</a></b><span>待获取书籍</span></div>
<div class="metric"><b><a href="/library">{len(owned) + counts['works']}</a></b><span>已入库作品</span></div>
<div class="metric"><b><a href="/sources">{counts['inbox_items']}</a></b><span>来源</span></div></div>
<div class="sectionhead"><h2>等待决定</h2><a href="/candidates?status=pending">查看全部</a></div><div class="media-list">{recent}</div>
<div class="split"><section><div class="sectionhead"><h2>最近活动</h2><a href="/activity">全部活动</a></div><div class="timeline">{activity}</div></section>
<section><h2>电子书存储</h2><div class="panel"><b>{html.escape(str(self.server.settings.library_root))}</b><p class="muted">{storage} · {counts['assets']} 个文件</p></div></section></div>"""
self.send_bytes(page("总览", body), "text/html; charset=utf-8")
def library(self, query: str = "") -> None:
params = urllib.parse.parse_qs(query)
media_filter = params.get("type", ["all"])[0]
works = self.server.database.works()
candidates = [item for item in self.server.database.all_media_candidates() if item["status"] == "owned" and item["media_type"] != "book"]
counts = {"book": len(works), "movie": 0, "tv": 0, "music": 0}
for item in candidates:
counts[item["media_type"]] = counts.get(item["media_type"], 0) + 1
segments = [('all', '全部', sum(counts.values())), ('book', '书籍', counts['book']), ('movie', '电影', counts['movie']), ('tv', '剧集', counts['tv']), ('music', '音乐', counts['music'])]
tabs = "".join(f'<a class="{"active" if key == media_filter else ""}" href="/library?type={key}">{label} {count}</a>' for key, label, count in segments)
items: list[str] = []
if media_filter in {"all", "book"}:
for work in works:
items.append(f'''<article class="library-item">{self.cover("work", int(work["id"]), "book", str(work["title"]))}<div><h3><a href="/work/{work['id']}">{html.escape(work['title'])}</a></h3>
<p>{html.escape(work['author'] or '未知作者')}</p><span class="tag tag-owned">{work['edition_count']} 个版本 · {work['asset_count']} 个文件</span></div></article>''')
for item in candidates:
if media_filter not in {"all", item["media_type"]}:
continue
matches = self.json_list(item["library_matches_json"])
match = matches[0] if matches else {}
quality = str(match.get("quality") or "")
instance = str(match.get("instance") or "")
details = " · ".join(value for value in (quality.upper(), instance, str(item["year"] or "")) if value)
items.append(f'''<article class="library-item">{self.cover("candidate", int(item["id"]), item['media_type'], str(item['title']))}<div><h3>{html.escape(item['title'])}</h3>
<p>{html.escape(item['original_title'] or item['creator'] or '')}</p><span class="tag tag-owned">{html.escape(details or '已入库')}</span></div></article>''')
content = "".join(items)
if not content:
suffix = f'当前有 <a href="/wanted">{self.server.database.counts()["wanted_books"]} 本待获取书籍</a>。' if media_filter in {"all", "book"} else ""
content = f'<div class="empty">这个分类还没有已入库作品。{suffix}</div>'
body = f'''<div class="pagehead"><div><h1>资料库</h1><p>只显示已有文件或媒体后端确认拥有的作品</p></div></div>
<div class="segments">{tabs}</div><div class="library-grid">{content}</div>'''
self.send_bytes(page("资料库", body), "text/html; charset=utf-8")
def upload_form(self, query: str = "") -> None:
params = urllib.parse.parse_qs(query)
title = html.escape(params.get("title", [""])[0], quote=True)
author = html.escape(params.get("author", [""])[0], quote=True)
work_id = int(params.get("work_id", ["0"])[0] or 0)
target = self.server.database.work(work_id) if work_id else None
if work_id and not target:
raise ValueError("指定的作品不存在")
target_notice = ""
if target:
target_notice = f'<div class="notice">作为《{html.escape(target["title"])}》的新语言或版本导入</div><input type="hidden" name="work_id" value="{work_id}">'
body = f"""<div class="pagehead"><div><h1>导入电子书</h1><p>EPUB / PDF 校验、查重并写入资料库</p></div></div>{target_notice}<div class="panel"><form method="post" action="/upload" enctype="multipart/form-data">
<div class="field"><label for="file">EPUB / PDF</label><input id="file" name="file" type="file" accept=".epub,.pdf" multiple required></div>
<div class="grid"><div class="field"><label for="title">书名</label><input id="title" name="title" value="{title}" placeholder="留空则尝试从 EPUB 读取"></div>
<div class="field"><label for="author">作者</label><input id="author" name="author" value="{author}"></div>
<div class="field"><label for="language">语言</label><select id="language" name="language"><option value="">自动识别</option>
<option value="zh-Hans">简体中文</option><option value="en">英文</option><option value="mul">中英混合</option><option value="und">未知</option></select></div>
<div class="field"><label for="variant">版本</label><select id="variant" name="variant"><option value="original">原版</option>
<option value="official-translation">官方译本</option><option value="ai-translation">AI 译本</option><option value="mixed">混合版本</option></select></div>
<div class="field"><label for="isbn">ISBN</label><input id="isbn" name="isbn"></div></div>
<button type="submit">校验并入库</button></form></div>"""
self.send_bytes(page("导入", body), "text/html; charset=utf-8")
def receive_upload(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > self.server.settings.max_upload_bytes:
raise ValueError("上传为空或超过大小上限")
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type:
raise ValueError("需要 multipart/form-data")
raw = self.rfile.read(length)
message = BytesParser(policy=default).parsebytes(
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("ascii") + raw
)
fields: dict[str, str] = {}
uploads: list[tuple[str, bytes]] = []
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition") or ""
if name == "file":
filename = Path(part.get_filename() or "upload.bin").name
payload = part.get_payload(decode=True) or b""
if payload:
uploads.append((filename, payload))
else:
fields[name] = decode_form_field(part)
if not uploads:
raise ValueError("没有收到文件")
if len(uploads) > 1 and any(fields.get(name, "") for name in ("title", "author", "isbn")):
raise ValueError("批量导入不能统一指定书名、作者或 ISBN")
imported = 0
duplicates = 0
failed = 0
for filename, payload in uploads:
job_id = self.server.database.create_job("book-import", filename)
job_dir = self.server.settings.staging_root / f"upload-{uuid.uuid4().hex}"
job_dir.mkdir(parents=True)
staged = job_dir / filename
staged.write_bytes(payload)
try:
result = self.server.library.import_file(
staged,
title=fields.get("title", "") if len(uploads) == 1 else "",
author=fields.get("author", "") if len(uploads) == 1 else "",
language=fields.get("language", ""),
variant=fields.get("variant", "original"),
isbn=fields.get("isbn", "") if len(uploads) == 1 else "",
source_name="web-upload",
work_id=int(fields.get("work_id", "0") or 0) or None,
)
state = "重复文件,未再次复制" if result.duplicate else "已校验并入库"
detail = f"{result.title} · {result.author or '未知作者'} · {state}"
self.server.database.reconcile_imported_book(result.title, result.author)
self.server.database.finish_job(job_id, "succeeded", detail)
duplicates += int(result.duplicate)
imported += int(not result.duplicate)
except Exception as exc:
failed += 1
self.server.database.finish_job(job_id, "failed", f"{filename}: {exc}")
finally:
shutil.rmtree(job_dir, ignore_errors=True)
summary = f"导入 {imported} · 重复 {duplicates} · 失败 {failed}"
self.redirect("/?" + urllib.parse.urlencode({"message": summary}))
def receive_wanted(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
values = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
query = values.get("query", [""])[0].strip()
if not query:
raise ValueError("请输入书名、作者、ISBN 或来源链接")
# A manual wishlist entry is still a write, so it is recorded like any
# other. media_type is book: this form only accepts books.
self.server.service.execute(WriteRequest(
action="add_wanted", media_type="book", title=query, channel="web", explicit=True,
))
self.redirect("/wanted")
def wanted(self) -> None:
candidates = self.server.database.all_media_candidates()
book_candidates = {
(str(item["title"] or "").casefold().strip(), str(item["creator"] or "").casefold().strip()): item
for item in candidates if item["media_type"] == "book"
}
cards: list[str] = []
matched_candidate_ids: set[int] = set()
for wanted in self.server.database.wanted():
if wanted["status"] != "wanted":
continue
title = str(wanted["title"] or wanted["query"] or "未命名书籍").strip()
author = str(wanted["author"] or "").strip()
candidate = book_candidates.get((title.casefold(), author.casefold()))
if candidate:
matched_candidate_ids.add(int(candidate["id"]))
cards.append(self.candidate_card(candidate))
continue
search_url = book_search_url(self.server.settings.zlib_search_url_template, title, author)
upload_query = urllib.parse.urlencode({"title": title, "author": author})
cards.append(f'''<article class="media-card">{self.cover("wanted", int(wanted["id"]), "book", title)}<div class="media-main"><div class="meta-row"><span class="tag media-tag">书籍</span><span class="tag tag-wanted">待获取</span></div>
<h3>{html.escape(title)}</h3>{f'<p class="byline">{html.escape(author)}</p>' if author else ''}<p class="source-note">加入于 {html.escape(self.short_time(wanted['created_at']))}</p></div>
<div class="media-actions"><a class="button secondary" href="{html.escape(search_url, quote=True)}" target="_blank" rel="noreferrer">查找 EPUB</a><a class="button quiet" href="/upload?{upload_query}">导入文件</a></div></article>''')
for candidate in candidates:
if candidate["status"] != "selected" or int(candidate["id"]) in matched_candidate_ids:
continue
cards.append(self.candidate_card(candidate))
listing = "".join(cards) or '<div class="empty">暂无待获取作品</div>'
body = f"""<div class="pagehead"><div><h1>待获取</h1><p>已决定收集、尚未确认入库的作品</p></div></div>
<div class="panel"><form method="post"><div class="grid"><div class="field"><label for="query">添加书籍</label><input id="query" name="query" placeholder="书名、作者或 ISBN" required></div>
<div class="field" style="align-self:end"><button type="submit">加入待获取</button></div></div></form></div>
<div class="sectionhead"><h2>{len(cards)} 项待处理</h2></div><div class="media-list">{listing}</div>"""
self.send_bytes(page("待获取", body), "text/html; charset=utf-8")
@staticmethod
def status_tag(status: str) -> str:
labels = {
"pending": "待决定", "owned": "已入库", "wanted": "待获取", "tracked": "已跟踪",
"selected": "已决定收集", "ignored": "已忽略", "not_recommended": "不建议收集",
"unknown": "未核对", "superseded": "已过期",
}
safe = html.escape(status)
return f'<span class="tag tag-{safe}">{html.escape(labels.get(status, status))}</span>'
def activity(self) -> None:
# Reads workflow_jobs, which is joined to the control ledger, so a write
# shows the action and risk tier it was classified as. The old
# activity_jobs table was a parallel log with no link to the plan that
# caused the row.
jobs = self.server.database.recent_jobs(100)
rows = ""
for job in jobs:
action = str(job["plan_action"] or "")
risk = str(job["plan_risk"] or "")
badge = f' <span class="tag">{html.escape(action)}·{html.escape(risk)}</span>' if action else ""
error = str(job["error"] or "")
detail = html.escape(str(job["detail"] or ""))
if error:
detail += f'<br><span class="muted">{html.escape(error)}</span>'
rows += (
f'<div class="timeline-row"><span>#{job["id"]}</span>'
f'<span class="status-{html.escape(str(job["status"]))}">{html.escape(str(job["status"]))}</span>'
f'<span><b>{html.escape(str(job["kind"]))}</b>{badge}<br>{detail}</span>'
f'<time>{html.escape(self.short_time(job["updated_at"]))}</time></div>'
)
rows = rows or '<div class="empty">暂无活动</div>'
body = f'<div class="pagehead"><div><h1>活动</h1><p>意图、计划与后台任务</p></div></div><div class="timeline">{rows}</div>'
self.send_bytes(page("活动", body), "text/html; charset=utf-8")
def sources(self) -> None:
items = "".join(
f'''<article class="source-item"><h3><a href="/source/{item['id']}">{html.escape(item['title'])}</a></h3><p class="muted">{html.escape(item['summary'] or '')}</p>
<div class="source-stats"><span>{item['discovered_count']} 部作品</span><span>{item['pending_count'] or 0} 待决定</span><span>{item['existing_count'] or 0} 已存在</span><span>{html.escape(self.short_time(item['updated_at']))}</span></div></article>'''
for item in self.server.database.sources()
) or '<div class="empty">还没有解析过来源</div>'
body = f"""<div class="pagehead"><div><h1>来源</h1><p>文章、书单与分享链接的解析记录</p></div></div><div class="source-list">{items}</div>"""
self.send_bytes(page("来源", body), "text/html; charset=utf-8")
def source(self, source_id: int) -> None:
source = self.server.database.inbox_item(source_id)
if not source or source["media_type"] != "source":
self.send_error(HTTPStatus.NOT_FOUND)
return
cards = "".join(self.candidate_card(item, show_source=False) for item in self.server.database.media_candidates(source_id) if item["status"] != "superseded")
cards = cards or '<div class="empty">这个来源没有提取出有效书影音作品</div>'
body = f"""<div class="pagehead"><div><h1>{html.escape(source['title'])}</h1><p>{html.escape(source['summary'])}</p></div>
<a class="button secondary" href="{html.escape(source['source_url'], quote=True)}" target="_blank" rel="noreferrer">打开原文</a></div>
<div class="sectionhead"><h2>提取作品</h2></div><div class="media-list">{cards}</div>"""
self.send_bytes(page(str(source["title"]), body), "text/html; charset=utf-8")
def candidates(self, query: str) -> None:
params = urllib.parse.parse_qs(query)
status = params.get("status", ["pending"])[0]
media_type = params.get("type", ["all"])[0]
all_items = self.server.database.all_media_candidates()
status_groups = {
"pending": {"pending"}, "selected": {"selected", "wanted"},
"owned": {"owned"}, "ignored": {"ignored", "not_recommended"},
"all": {str(item["status"]) for item in all_items},
}
items = [item for item in all_items if item["status"] in status_groups.get(status, status_groups["pending"])]
if media_type != "all":
items = [item for item in items if item["media_type"] == media_type]
status_labels = [("pending", "待决定"), ("selected", "已选择"), ("owned", "已入库"), ("ignored", "已忽略"), ("all", "全部")]
status_tabs = "".join(
f'<a class="{"active" if key == status else ""}" href="/candidates?status={key}&type={media_type}">{label}</a>'
for key, label in status_labels
)
type_labels = [("all", "全部类型"), ("book", "书籍"), ("movie", "电影"), ("tv", "剧集"), ("music", "音乐")]
type_tabs = "".join(
f'<a class="{"active" if key == media_type else ""}" href="/candidates?status={status}&type={key}">{label}</a>'
for key, label in type_labels
)
cards = "".join(self.candidate_card(item) for item in items) or '<div class="empty">没有符合条件的作品</div>'
body = f"""<div class="pagehead"><div><h1>发现</h1><p>从来源中识别的作品与收集判断</p></div><span class="muted">{len(items)} 项</span></div>
<div class="segments">{status_tabs}</div><div class="segments">{type_tabs}</div><div class="media-list">{cards}</div>"""
self.send_bytes(page("发现", body), "text/html; charset=utf-8")
def update_candidate(self, candidate_id: int) -> None:
item = self.server.database.media_candidate(candidate_id)
if not item:
self.send_error(HTTPStatus.NOT_FOUND)
return
length = int(self.headers.get("Content-Length", "0"))
values = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
action = values.get("action", [""])[0]
next_path = values.get("next", [f"/source/{item['inbox_item_id']}"])[0]
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/candidates"
if item["status"] != "pending":
self.redirect(next_path)
return
if action not in {"collect", "ignore"}:
raise ValueError("未知候选操作")
# Routed through the service so that a decision made in the browser
# produces the same ledger as the same decision made in Telegram. This
# branch previously wrote no ledger at all, and for a film or series it
# only marked the candidate "selected" without ever calling the adapter --
# so "collect" here and "collect" in the chat did different things.
try:
metadata = json.loads(item["metadata_json"] or "{}")
except (json.JSONDecodeError, TypeError):
metadata = {}
self.server.service.execute(WriteRequest(
action="collect" if action == "collect" else "ignore_candidate",
media_type=str(item["media_type"] or "unknown"),
title=str(item["title"] or ""),
creator=str(item["creator"] or ""),
channel="web",
year=int(item["year"]) if item["year"] else None,
identity={
str(key): str(value)
for key, value in (metadata.get("external_ids") or {}).items()
if value
},
candidate_id=candidate_id,
explicit=True,
source={
"original_title": str(item["original_title"] or ""),
"aliases": metadata.get("aliases") or [],
},
))
self.redirect(next_path)
def work(self, work_id: int) -> None:
work = self.server.database.work(work_id)
if not work:
self.send_error(HTTPStatus.NOT_FOUND)
return
rows = ""
for asset in self.server.database.work_assets(work_id):
metadata = json.loads(asset["metadata_json"] or "{}")
edition_title = str(metadata.get("display_title") or "")
title_note = f'<br><span class="muted">{html.escape(edition_title)}</span>' if edition_title and edition_title != work["title"] else ""
read = f'<a class="button" href="/reader/{asset["id"]}">阅读</a> ' if asset["format"] in {"epub", "pdf"} else ""
rows += f"<tr><td>{html.escape(asset['language'])}{title_note}</td><td>{html.escape(asset['variant'])}</td>"
rows += f"<td>{html.escape(asset['format'].upper())}</td><td>{asset['size_bytes'] / 1024 / 1024:.1f} MB</td>"
rows += f'<td>{read}<a class="button secondary" href="/download/{asset["id"]}">下载</a></td></tr>'
body = f"""<div class="pagehead"><div><h1>{html.escape(work['title'])}</h1><p>{html.escape(work['author'] or '未知作者')}</p></div><a class="button" href="/upload?work_id={work_id}">添加语言或版本</a></div>
<div class="scroll"><table><thead><tr><th>语言</th><th>版本</th><th>格式</th><th>大小</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table></div>"""
self.send_bytes(page(str(work["title"]), body), "text/html; charset=utf-8")
def cover_asset(self, kind: str, entity_id: int) -> None:
try:
path = self.server.covers.ensure(kind, entity_id)
except Exception:
path = None
if not path:
self.send_error(HTTPStatus.NOT_FOUND)
return
content_type = {".jpg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}.get(path.suffix.lower())
if not content_type:
self.send_error(HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
return
self.send_bytes(path.read_bytes(), content_type)
def reader(self, asset_id: int, query: str) -> None:
asset = self.server.database.asset(asset_id)
if not asset:
self.send_error(HTTPStatus.NOT_FOUND)
return
if asset["format"] == "pdf":
body = f'<h1>{html.escape(asset["title"])}</h1><iframe class="reader" sandbox="" src="/download/{asset_id}?inline=1"></iframe>'
elif asset["format"] == "epub":
metadata = json.loads(asset["metadata_json"])
spine = metadata.get("spine", [])
if not spine:
raise ValueError("EPUB 没有可读取章节")
params = urllib.parse.parse_qs(query)
chapter = max(0, min(int(params.get("chapter", ["0"])[0]), len(spine) - 1))
previous = max(0, chapter - 1)
following = min(len(spine) - 1, chapter + 1)
member = urllib.parse.quote(spine[chapter], safe="/")
body = f"""<div class="readerbar"><span>{html.escape(asset['title'])} · {chapter + 1}/{len(spine)}</span>
<a class="button secondary" href="/reader/{asset_id}?chapter={previous}">上一章</a>
<a class="button" href="/reader/{asset_id}?chapter={following}">下一章</a></div>
<iframe class="reader" sandbox="" src="/epub/{asset_id}/{member}"></iframe>"""
else:
raise ValueError("该格式暂不支持在线阅读")
self.send_bytes(page("阅读", body), "text/html; charset=utf-8")
def epub_member(self, asset_id: int, member: str) -> None:
asset = self.server.database.asset(asset_id)
if not asset or asset["format"] != "epub":
self.send_error(HTTPStatus.NOT_FOUND)
return
data, mime = read_member(Path(asset["path"]), member)
if mime in {"application/xhtml+xml", "text/html"}:
text = data.decode("utf-8", errors="replace")
inject = "<base href=\"/epub/%d/%s/\"><style>body{max-width:760px;margin:28px auto;padding:0 18px;font:18px/1.75 serif;color:#202124}img{max-width:100%%}</style>" % (
asset_id,
urllib.parse.quote(str(Path(member).parent), safe="/"),
)
text = text.replace("<head>", "<head>" + inject, 1) if "<head>" in text else inject + text
data = text.encode("utf-8")
mime = "text/html; charset=utf-8"
self._sandboxed(data, mime)
def download(self, asset_id: int) -> None:
asset = self.server.database.asset(asset_id)
if not asset:
self.send_error(HTTPStatus.NOT_FOUND)
return
path = Path(asset["path"])
if not path.is_file():
self.send_error(HTTPStatus.GONE)
return
inline = "inline=1" in urllib.parse.urlparse(self.path).query
quoted = urllib.parse.quote(asset["filename"])
if inline:
with path.open("rb") as handle:
data = handle.read()
self._sandboxed(data, asset["mime_type"], filename=quoted)
return
self.send_response(200)
self.send_header("Content-Type", asset["mime_type"])
self.send_header("Content-Length", str(path.stat().st_size))
self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quoted}")
self.end_headers()
with path.open("rb") as handle:
shutil.copyfileobj(handle, self.wfile)
def health(self) -> None:
payload = {
"status": "ok" if os.access(self.server.settings.library_root, os.W_OK) else "degraded",
"database": str(self.server.settings.database),
"library_root": str(self.server.settings.library_root),
"library_writable": os.access(self.server.settings.library_root, os.W_OK),
"staging_writable": os.access(self.server.settings.staging_root, os.W_OK),
"counts": self.server.database.counts(),
"telegram_configured": bool(self.server.settings.telegram_token),
"pi_model": self.server.settings.pi_model,
"pi_thinking": self.server.settings.pi_thinking,
"pi_fallback_model": self.server.settings.pi_fallback_model,
"pi_timeout_seconds": self.server.settings.pi_timeout_seconds,
"catalogs": {
"radarr": bool(self.server.settings.radarr_url and self.server.settings.radarr_api_key),
"radarr_4k": bool(self.server.settings.radarr_4k_url and self.server.settings.radarr_4k_api_key),
"sonarr": bool(self.server.settings.sonarr_url and self.server.settings.sonarr_api_key),
"sonarr_4k": bool(self.server.settings.sonarr_4k_url and self.server.settings.sonarr_4k_api_key),
"plex_music": bool(self.server.settings.plex_url and self.server.settings.plex_token),
"book_reviews": ["douban/goodreads public pages", "tavily/duckduckgo+llm"],
},
}
self.send_bytes(json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def api_books(self) -> None:
payload = [dict(row) for row in self.server.database.works()]
self.send_bytes(json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def api_sources(self) -> None:
payload = [dict(row) for row in self.server.database.sources()]
self.send_bytes(json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def api_candidates(self, query: str) -> None:
params = urllib.parse.parse_qs(query)
status = params.get("status", [""])[0]
payload = []
for row in self.server.database.all_media_candidates(status=status):
item = dict(row)
item["library_matches"] = json.loads(item.pop("library_matches_json") or "[]")
item["reasons"] = json.loads(item.pop("reasons_json") or "[]")
item["metadata"] = json.loads(item.pop("metadata_json") or "{}")
if item["media_type"] == "book" and item["library_state"] != "owned":
item["manual_search"] = {
"zlibrary": book_search_url(
self.server.settings.zlib_search_url_template,
str(item["title"] or ""),
str(item["creator"] or ""),
)
}
payload.append(item)
self.send_bytes(json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def serve(settings: Settings, database: Database) -> None:
_warn_if_open(settings)
server = CuratorServer((settings.host, settings.port), settings, database)
server.serve_forever()
def _warn_if_open(settings: Settings) -> None:
"""Refuse silently opening the write surface without a token.
An unauthenticated web UI is only defensible on loopback. On a wide-open
bind, running without CURATOR_WEB_TOKEN turns any LAN host into a write
primitive, and the operator should see that loudly in the journal.
"""
import logging
if settings.web_token:
return
host = settings.host
loopback = host in ("127.0.0.1", "::1", "localhost") or host.startswith("127.")
level = logging.getLogger("curator.web").info if loopback else logging.getLogger("curator.web").warning
level(
"web UI has no CURATOR_WEB_TOKEN set and is bound to %s (%s)",
host,
"loopback only" if loopback else "ALL INTERFACES — the library is writable by anyone who can reach this port",
)
def serve_in_thread(settings: Settings, database: Database) -> tuple[CuratorServer, threading.Thread]:
server = CuratorServer((settings.host, settings.port), settings, database)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread