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""" {html.escape(title)} · Curator
{body}
""" 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'
{html.escape(message)}
' if message else "" body = f"""

登录

Curator 需要访问令牌

{notice}
""" 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'' return (f'
{image}{html.escape(self.media_label(safe_type))}' f'{html.escape(title)}
') 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'{html.escape(self.media_label(media_type))}', self.status_tag(str(item["status"]))] if recommendation != "待判断": tags.append(f'{html.escape(recommendation)}') 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'{html.escape(text)}') 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'{html.escape(";".join(rating_bits))}') 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'{html.escape(label)}') columns = "" if strengths: columns += '
值得关注
' if caveats: columns += '
需要留意
' if columns or source_links: sources = ('

评价来源:' + " · ".join(source_links) + '

') if source_links else "" review_detail = f'
评价依据
{columns}
{sources}
' actions = [] if item["status"] == "pending": next_target = "/candidates?status=pending" if show_source else f'/source/{item["inbox_item_id"]}' actions.extend([ f'
', f'
', ]) 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'查找 EPUB') upload_query = urllib.parse.urlencode({"title": title, "author": creator}) actions.append(f'导入文件') if show_source and item["inbox_item_id"]: actions.append(f'查看来源') source_line = "" if show_source and item["inbox_item_id"]: source_line = f'

来自 {html.escape(item["source_title"])}

' return f'''
{self.cover("candidate", int(item["id"]), media_type, title)}
{"".join(tags)}

{html.escape(title)}

{f'' if byline else ''} {f'

{html.escape(summary)}

' if summary else ''}{review_detail}{source_line}
{"".join(actions)}
''' 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'
{html.escape(str(exc))}
'), "text/html; charset=utf-8", 400) except Exception as exc: self.send_bytes(page("服务错误", f'
{html.escape(str(exc))}
'), "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'
{html.escape(str(exc))}
返回'), "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'
{html.escape(params["message"][0])}
' recent = "".join(self.candidate_card(item, compact=True) for item in pending[:4]) recent = recent or '
没有等待决定的作品
' jobs = self.server.database.recent_jobs(5) activity = "".join( f'
#{job["id"]}{html.escape(job["status"])}' f'{html.escape(job["detail"] or job["kind"])}
' for job in jobs ) or '
暂无活动
' writable = os.access(self.server.settings.library_root, os.W_OK) storage = "可写" if writable else "只读,导入将被阻止" body = f"""{notice}

总览

书、影、音的决策与入库状态

导入电子书
{len(pending)}待决定
{counts['wanted_books']}待获取书籍
{len(owned) + counts['works']}已入库作品
{counts['inbox_items']}来源

等待决定

查看全部
{recent}

最近活动

全部活动
{activity}

电子书存储

{html.escape(str(self.server.settings.library_root))}

{storage} · {counts['assets']} 个文件

""" 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'{label} {count}' for key, label, count in segments) items: list[str] = [] if media_filter in {"all", "book"}: for work in works: items.append(f'''
{self.cover("work", int(work["id"]), "book", str(work["title"]))}

{html.escape(work['title'])}

{html.escape(work['author'] or '未知作者')}

{work['edition_count']} 个版本 · {work['asset_count']} 个文件
''') 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'''
{self.cover("candidate", int(item["id"]), item['media_type'], str(item['title']))}

{html.escape(item['title'])}

{html.escape(item['original_title'] or item['creator'] or '')}

{html.escape(details or '已入库')}
''') content = "".join(items) if not content: suffix = f'当前有 {self.server.database.counts()["wanted_books"]} 本待获取书籍。' if media_filter in {"all", "book"} else "" content = f'
这个分类还没有已入库作品。{suffix}
' body = f'''

资料库

只显示已有文件或媒体后端确认拥有的作品

{tabs}
{content}
''' 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'
作为《{html.escape(target["title"])}》的新语言或版本导入
' body = f"""

导入电子书

EPUB / PDF 校验、查重并写入资料库

{target_notice}
""" 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'''
{self.cover("wanted", int(wanted["id"]), "book", title)}
书籍待获取

{html.escape(title)}

{f'' if author else ''}

加入于 {html.escape(self.short_time(wanted['created_at']))}

查找 EPUB导入文件
''') 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 '
暂无待获取作品
' body = f"""

待获取

已决定收集、尚未确认入库的作品

{len(cards)} 项待处理

{listing}
""" 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'{html.escape(labels.get(status, status))}' 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' {html.escape(action)}·{html.escape(risk)}' if action else "" error = str(job["error"] or "") detail = html.escape(str(job["detail"] or "")) if error: detail += f'
{html.escape(error)}' rows += ( f'
#{job["id"]}' f'{html.escape(str(job["status"]))}' f'{html.escape(str(job["kind"]))}{badge}
{detail}
' f'
' ) rows = rows or '
暂无活动
' body = f'

活动

意图、计划与后台任务

{rows}
' self.send_bytes(page("活动", body), "text/html; charset=utf-8") def sources(self) -> None: items = "".join( f'''

{html.escape(item['title'])}

{html.escape(item['summary'] or '')}

{item['discovered_count']} 部作品{item['pending_count'] or 0} 待决定{item['existing_count'] or 0} 已存在{html.escape(self.short_time(item['updated_at']))}
''' for item in self.server.database.sources() ) or '
还没有解析过来源
' body = f"""

来源

文章、书单与分享链接的解析记录

{items}
""" 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 '
这个来源没有提取出有效书影音作品
' body = f"""

{html.escape(source['title'])}

{html.escape(source['summary'])}

打开原文

提取作品

{cards}
""" 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'{label}' for key, label in status_labels ) type_labels = [("all", "全部类型"), ("book", "书籍"), ("movie", "电影"), ("tv", "剧集"), ("music", "音乐")] type_tabs = "".join( f'{label}' for key, label in type_labels ) cards = "".join(self.candidate_card(item) for item in items) or '
没有符合条件的作品
' body = f"""

发现

从来源中识别的作品与收集判断

{len(items)} 项
{status_tabs}
{type_tabs}
{cards}
""" 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'
{html.escape(edition_title)}' if edition_title and edition_title != work["title"] else "" read = f'阅读 ' if asset["format"] in {"epub", "pdf"} else "" rows += f"{html.escape(asset['language'])}{title_note}{html.escape(asset['variant'])}" rows += f"{html.escape(asset['format'].upper())}{asset['size_bytes'] / 1024 / 1024:.1f} MB" rows += f'{read}下载' body = f"""

{html.escape(work['title'])}

{html.escape(work['author'] or '未知作者')}

添加语言或版本
{rows}
语言版本格式大小操作
""" 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'

{html.escape(asset["title"])}

' 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"""
{html.escape(asset['title'])} · {chapter + 1}/{len(spine)} 上一章 下一章
""" 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 = "" % ( asset_id, urllib.parse.quote(str(Path(member).parent), safe="/"), ) text = text.replace("", "" + inject, 1) if "" 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