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.
968 lines
58 KiB
Python
968 lines
58 KiB
Python
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
|