From 72dde71849102e708ad1a0952aac2a2c05ba517a Mon Sep 17 00:00:00 2001 From: HallowDem <75336799+Cytrogen@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:08:45 -0400 Subject: [PATCH] feat(web): FastAPI UI and Kobo sync protocol endpoints Progressive enhancement web app: works without JS (plain HTML forms + full page reloads), upgrades to HTMX partial swaps + SSE task progress when JS is available. UI routes (app.py + templates/): - / search homepage - /search multi-source manga search (HTMX partial for results) - /manga/{source}/{id} detail page with chapter list + download form - /download enqueue background download task - /tasks task list with live progress via SSE - /api/cover cover proxy (handles anti-hotlink referer per source) Background task manager (tasks.py): - In-memory TaskStatus registry with up to MAX_FINISHED_TASKS cap - cancel_task guards against pipelines that don't implement cancel_download (current MangaPipeline is sync-only) Kobo sync router (kobo_sync.py): - Full sync protocol implementation compatible with Kobo firmware, cribbed from calibre-web cps/kobo.py. Key gotchas documented inline: * Format must be EPUB3FL (not KEPUB) for pre-paginated manga * metadata endpoint must return [metadata] list, not bare dict * mark_synced must fire on download, not on sync (no SyncToken delta) * thumbnail endpoint extracts cover.jpg from KEPUB zip - All endpoints guard on auth_token via kobo_devices table Security fixes applied during review: - log_all_requests middleware redacts /kobo// before logging and runs at DEBUG level so auth tokens don't end up in log files - cancel_task uses getattr guard so pipelines missing cancel_download don't crash with AttributeError --- src/kobo_manga/web/__init__.py | 0 src/kobo_manga/web/app.py | 277 ++++++ src/kobo_manga/web/cover_proxy.py | 76 ++ src/kobo_manga/web/kobo_sync.py | 838 ++++++++++++++++++ src/kobo_manga/web/static/style.css | 294 ++++++ src/kobo_manga/web/tasks.py | 168 ++++ .../web/templates/_search_results.html | 26 + src/kobo_manga/web/templates/base.html | 32 + src/kobo_manga/web/templates/manga.html | 75 ++ src/kobo_manga/web/templates/search.html | 20 + src/kobo_manga/web/templates/tasks.html | 69 ++ 11 files changed, 1875 insertions(+) create mode 100644 src/kobo_manga/web/__init__.py create mode 100644 src/kobo_manga/web/app.py create mode 100644 src/kobo_manga/web/cover_proxy.py create mode 100644 src/kobo_manga/web/kobo_sync.py create mode 100644 src/kobo_manga/web/static/style.css create mode 100644 src/kobo_manga/web/tasks.py create mode 100644 src/kobo_manga/web/templates/_search_results.html create mode 100644 src/kobo_manga/web/templates/base.html create mode 100644 src/kobo_manga/web/templates/manga.html create mode 100644 src/kobo_manga/web/templates/search.html create mode 100644 src/kobo_manga/web/templates/tasks.html diff --git a/src/kobo_manga/web/__init__.py b/src/kobo_manga/web/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/kobo_manga/web/app.py b/src/kobo_manga/web/app.py new file mode 100644 index 0000000000000000000000000000000000000000..8789be8c7b50e4b29e18714491113673e97f5b4e --- /dev/null +++ b/src/kobo_manga/web/app.py @@ -0,0 +1,277 @@ +"""FastAPI Web UI 应用 + +sourcehut 风格渐进增强: +- 基线:纯 HTML 表单 + 页面刷新,关闭 JS 也能完整使用 +- 增强:HTMX 局部刷新 + SSE 实时进度 +""" + +import asyncio +import json +import logging +import re +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Form, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from kobo_manga.config import load_config +from kobo_manga.db.database import Database +from kobo_manga.db.queries import get_manga +from kobo_manga.pipeline import MangaPipeline +from kobo_manga.web.cover_proxy import fetch_cover +from kobo_manga.web.tasks import ( + cancel_task, + create_download_task, + get_all_tasks, + get_task, +) + +logger = logging.getLogger(__name__) + +WEB_DIR = Path(__file__).parent +TEMPLATES_DIR = WEB_DIR / "templates" +STATIC_DIR = WEB_DIR / "static" + + +def _render(request: Request, name: str, **ctx): + """渲染模板的便捷方法。""" + return templates.TemplateResponse(request=request, name=name, context=ctx) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理。""" + logger.info("Web UI 启动") + db = Database() + db.initialize() + app.state.db = db + yield + db.close() + logger.info("Web UI 关闭") + + +app = FastAPI(title="kobo-manga", lifespan=lifespan) +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +# 用来把 /kobo//... 里的 token 段脱敏,避免进日志。 +_KOBO_TOKEN_RE = re.compile(r"/kobo/[^/?#]+") + + +def _redact_kobo_token(url: str) -> str: + return _KOBO_TOKEN_RE.sub("/kobo/", url) + + +@app.middleware("http") +async def log_all_requests(request: Request, call_next): + """记录所有 HTTP 请求(debug 级,脱敏 Kobo auth_token)。""" + if logger.isEnabledFor(logging.DEBUG): + safe_url = _redact_kobo_token(str(request.url)) + client = request.client.host if request.client else "?" + logger.debug(">>> %s %s (from %s)", request.method, safe_url, client) + return await call_next(request) + + +def _manga_url(source: str, manga_id: str) -> str: + """根据源和 ID 构造漫画 URL。""" + urls = { + "manhuagui": f"https://www.manhuagui.com/comic/{manga_id}/", + "mangadex": f"https://mangadex.org/title/{manga_id}", + } + return urls.get(source, "") + + +def _is_htmx(request: Request) -> bool: + """检查是否为 HTMX 请求。""" + return request.headers.get("HX-Request") == "true" + + +def _get_db() -> Database: + """获取共享数据库连接。回退到新建连接(测试场景)。""" + from starlette.requests import Request as _ # ensure app is importable + try: + return app.state.db + except AttributeError: + db = Database() + db.initialize() + return db + + +# ── 路由 ───────────────────────────────────────────────── + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + """搜索首页。""" + return _render(request, "search.html", results=None, query="", error=None) + + +@app.get("/search", response_class=HTMLResponse) +async def search( + request: Request, + q: str = Query(default=""), + source: str = Query(default=""), +): + """搜索漫画。""" + if not q.strip(): + return _render(request, "search.html", results=None, query=q, error=None) + + config = load_config() + db = _get_db() + try: + pipeline = MangaPipeline(config, db) + source_name = source if source else None + results = await pipeline.search(q.strip(), source_name=source_name) + except Exception as e: + logger.error("搜索失败: %s", e) + return _render(request, "search.html", results=[], query=q, error=str(e)) + + if _is_htmx(request): + return _render(request, "_search_results.html", results=results, query=q, error=None) + + return _render(request, "search.html", results=results, query=q, error=None) + + +@app.get("/manga/{source}/{manga_id}", response_class=HTMLResponse) +async def manga_detail(request: Request, source: str, manga_id: str): + """漫画详情页。""" + config = load_config() + db = _get_db() + try: + pipeline = MangaPipeline(config, db) + manga = get_manga(db, manga_id, source) + url = manga.url if (manga and manga.url) else _manga_url(source, manga_id) + if not url: + return _render(request, "manga.html", manga=None, error=f"未知源: {source}") + manga = await pipeline.get_manga_info(url, source_name=source) + except Exception as e: + logger.error("获取漫画详情失败: %s", e) + return _render(request, "manga.html", manga=None, error=str(e)) + + return _render(request, "manga.html", manga=manga, error=None) + + +@app.post("/download") +async def download( + request: Request, + manga_id: str = Form(...), + source: str = Form(...), + chapters: str = Form(default="all"), + chapter_type: str = Form(default=""), +): + """提交下载任务。""" + if chapters and chapters != "all": + try: + from kobo_manga.utils import parse_chapter_range + parse_chapter_range(chapters) + except (ValueError, IndexError): + if _is_htmx(request): + return HTMLResponse( + '
无效的章节范围格式
', + status_code=400, + ) + return RedirectResponse( + f"/manga/{source}/{manga_id}?error=invalid_range", + status_code=303, + ) + + task = create_download_task( + manga_id=manga_id, + source=source, + chapters=chapters, + chapter_type=chapter_type if chapter_type else None, + ) + + if _is_htmx(request): + return HTMLResponse( + f'
任务已创建: {task.id}
' + f'' + ) + + return RedirectResponse("/tasks", status_code=303) + + +@app.get("/tasks", response_class=HTMLResponse) +async def task_list(request: Request): + """任务列表页。""" + tasks = get_all_tasks() + has_active = any(t.status in ("pending", "downloading", "converting") for t in tasks) + return _render(request, "tasks.html", tasks=tasks, has_active=has_active) + + +@app.get("/tasks/{task_id}/events") +async def task_events(task_id: str): + """SSE 进度流。""" + async def event_stream(): + last_status = None + last_progress = -1 + + while True: + task = get_task(task_id) + if not task: + yield f"event: error\ndata: {json.dumps({'error': 'task not found'})}\n\n" + return + + if task.status != last_status or task.progress != last_progress: + last_status = task.status + last_progress = task.progress + + data = { + "task_id": task.id, + "status": task.status, + "progress": task.progress, + "manga_title": task.manga_title, + "current_chapter": task.current_chapter, + "chapters_done": task.chapters_done, + "chapters_total": task.chapters_total, + "error_message": task.error_message, + } + yield f"event: progress\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + if task.status in ("done", "failed", "cancelled"): + yield f"event: {task.status}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + return + + await asyncio.sleep(2) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.post("/tasks/{task_id}/cancel") +async def cancel(request: Request, task_id: str): + """取消下载任务。""" + cancel_task(task_id) + if _is_htmx(request): + return HTMLResponse('
任务已取消
') + return RedirectResponse("/tasks", status_code=303) + + +# ── Kobo Sync Router ──────────────────────────────────── +from kobo_manga.web.kobo_sync import router as kobo_sync_router +app.include_router(kobo_sync_router) + + +@app.get("/api/cover") +async def cover_proxy( + url: str = Query(...), + source: str = Query(default=""), +): + """封面图片代理。""" + result = await fetch_cover(url, source) + if result is None: + return Response(status_code=404) + + content_type, data = result + return Response( + content=data, + media_type=content_type, + headers={"Cache-Control": "public, max-age=86400"}, + ) diff --git a/src/kobo_manga/web/cover_proxy.py b/src/kobo_manga/web/cover_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..7d2318d870030d5f0c0aa2de74b8263e7b2146f3 --- /dev/null +++ b/src/kobo_manga/web/cover_proxy.py @@ -0,0 +1,76 @@ +"""封面图片代理 + +代理漫画封面图请求,解决防盗链和 CORS 问题。 +使用内存 LRU 缓存避免重复请求。 +""" + +import hashlib +import logging +from functools import lru_cache + +import httpx + +logger = logging.getLogger(__name__) + +# 各源的 Referer 映射 +_SOURCE_REFERERS: dict[str, str] = { + "manhuagui": "https://www.manhuagui.com/", + "mangadex": "https://mangadex.org/", +} + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), +} + + +@lru_cache(maxsize=256) +def _cache_key(url: str) -> str: + """生成缓存键。""" + return hashlib.md5(url.encode()).hexdigest() + + +# 内存缓存: hash -> (content_type, bytes) +_cover_cache: dict[str, tuple[str, bytes]] = {} +_MAX_CACHE = 256 + + +async def fetch_cover(url: str, source: str | None = None) -> tuple[str, bytes] | None: + """获取封面图片。 + + Returns: + (content_type, image_bytes) 或 None + """ + cache_key = _cache_key(url) + if cache_key in _cover_cache: + return _cover_cache[cache_key] + + referer = _SOURCE_REFERERS.get(source or "", "") + headers = {**_HEADERS} + if referer: + headers["Referer"] = referer + + try: + async with httpx.AsyncClient( + follow_redirects=True, timeout=15.0 + ) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + + content_type = resp.headers.get("content-type", "image/jpeg") + data = resp.content + + # 缓存(淘汰最旧的) + if len(_cover_cache) >= _MAX_CACHE: + oldest_key = next(iter(_cover_cache)) + del _cover_cache[oldest_key] + _cover_cache[cache_key] = (content_type, data) + + return content_type, data + + except Exception as e: + logger.warning("封面获取失败 %s: %s", url[:80], e) + return None diff --git a/src/kobo_manga/web/kobo_sync.py b/src/kobo_manga/web/kobo_sync.py new file mode 100644 index 0000000000000000000000000000000000000000..0a9b6d63d30c47d4f9da64795e92eb89db7cf7e7 --- /dev/null +++ b/src/kobo_manga/web/kobo_sync.py @@ -0,0 +1,838 @@ +"""Kobo Sync Protocol 实现 + +实现 Kobo 设备的同步协议端点,让 Kobo 通过 WiFi 自动拉取新书。 +参考 calibre-web (cps/kobo.py) 和 Komga 的 Kobo sync 实现。 +""" + +import base64 +import json as json_mod +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import FileResponse, JSONResponse + +from kobo_manga.db.database import Database +from kobo_manga.db.queries import ( + get_device_by_token, + get_pending_syncs, + mark_synced, + update_device_sync_time, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/kobo/{auth_token}", tags=["kobo-sync"]) + +# Kobo 官方 API 的基础 URL(用于 initialization 响应中不需要代理的服务) +KOBO_STOREAPI_URL = "https://storeapi.kobo.com" + + +def _get_db(request: Request) -> Database: + """从 app state 获取共享 DB。""" + return request.app.state.db + + +def _utc_now() -> str: + """返回 UTC ISO 8601 时间戳。""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _validate_token(auth_token: str, db: Database) -> dict | None: + """验证 auth_token,返回设备信息或 None。""" + return get_device_by_token(db, auth_token) + + +# ── Initialization ────────────────────────────────────── + + +@router.get("/v1/initialization") +async def initialization( + request: Request, + auth_token: str, + db: Database = Depends(_get_db), +): + """设备初始化,返回服务器能力和资源 URL。 + + Kobo 设备首次连接时调用此端点获取各种服务的 URL。 + 使用完整的 Kobo 官方资源列表作为基础,只覆盖图书同步相关的 URL。 + 参考 calibre-web cps/kobo.py NATIVE_KOBO_RESOURCES。 + """ + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + + base_url = str(request.base_url).rstrip("/") + kobo_base = f"{base_url}/kobo/{auth_token}" + + # 从完整的 Kobo 原始资源开始,只覆盖我们需要代理的 + resources = _native_kobo_resources() + resources.update({ + "image_host": base_url, + "image_url_quality_template": f"{kobo_base}/v1/library/{{ImageId}}/thumbnail/{{Width}}/{{Height}}/false/image.jpg", + "image_url_template": f"{kobo_base}/v1/library/{{ImageId}}/thumbnail/{{Width}}/{{Height}}/false/image.jpg", + "library_sync": f"{kobo_base}/v1/library/sync", + "library_items": f"{kobo_base}/v1/library/{{BookId}}", + "content_access_book": f"{kobo_base}/v1/library/{{BookId}}/content", + "reading_state": f"{kobo_base}/v1/library/{{BookId}}/state", + "tags": f"{kobo_base}/v1/library/tags", + # 下载相关:Kobo 下载管理器可能用这些而不是 DownloadUrls + "get_download_keys": f"{kobo_base}/v1/library/downloadkeys", + "get_download_link": f"{kobo_base}/v1/library/downloadlink", + }) + + return JSONResponse( + {"Resources": resources}, + headers={"x-kobo-apitoken": "e30="}, + ) + + +def _native_kobo_resources() -> dict: + """Kobo 官方 API 的完整资源列表(fallback,不走代理时使用)。 + + 从 calibre-web 的 NATIVE_KOBO_RESOURCES 精简而来, + 包含 Kobo 固件期望存在的所有 key。 + """ + store = KOBO_STOREAPI_URL + return { + "account_page": "https://www.kobo.com/account/settings", + "account_page_rakuten": "https://my.rakuten.co.jp/", + "add_entitlement": f"{store}/v1/library/{{BookId}}", + "affiliaterequest": f"{store}/v1/affiliate", + "audiobook_landing_page": "https://www.kobo.com/audiobooks", + "audiobook_subscription_orange_deal_inclusion_url": "https://www.kobo.com/{{region}}/{{language}}/audiobook-subscription", + "autocomplete": f"{store}/v1/products/autocomplete", + "blackstone_header": { + "key": "x-]8YT}6eK9Cj", + "value": "SmFjaz1AcnlASE8jbmpCUUBrcGNRQHQ=", + }, + "book": f"{store}/v1/products/books/{{ProductId}}", + "book_detail_page": "https://www.kobo.com/{{region}}/{{language}}/ebook/{{slug}}", + "book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{{crossrevisionid}}", + "book_landing_page": "https://www.kobo.com/ebooks", + "book_subscription": f"{store}/v1/products/books/series/{{SeriesId}}", + "categories": f"{store}/v1/categories", + "categories_page": "https://www.kobo.com/{{region}}/{{language}}/categories", + "category": f"{store}/v1/categories/{{CategoryId}}", + "category_featured_lists": f"{store}/v1/categories/{{CategoryId}}/featured", + "category_products": f"{store}/v1/categories/{{CategoryId}}/products", + "checkout_borrowed_book": f"{store}/v1/library/{{BookId}}/checkout", + "configuration_data": f"{store}/v1/configuration", + "content_access_book": f"{store}/v1/products/books/{{ProductId}}/access", + "customer_care_live_chat": "https://help.kobo.com/hc/en-us", + "daily_deal": f"{store}/v1/deals/dailydeal", + "deals": f"{store}/v1/deals", + "delete_entitlement": f"{store}/v1/library/{{BookId}}", + "delete_tag": f"{store}/v1/library/tags/{{TagId}}", + "delete_tag_items": f"{store}/v1/library/tags/{{TagId}}/items/delete", + "device_auth": "https://storeapi.kobo.com/v1/auth/device", + "device_refresh": "https://storeapi.kobo.com/v1/auth/refresh", + "dictionary_host": "https://ereaderfiles.kobo.com", + "discovery_host": f"{store}", + "eula_page": "https://www.kobo.com/termsofuse?style=onestore", + "exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange", + "external_book": f"{store}/v1/products/books/external/{{Ids}}", + "facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com", + "featured_list": f"{store}/v1/products/featured/{{FeaturedListId}}", + "featured_lists": f"{store}/v1/products/featured", + "free_books_page": { + "EN": "https://www.kobo.com/{{region}}/{{language}}/p/free-ebooks", + "FR": "https://www.kobo.com/{{region}}/{{language}}/p/livres-gratuits", + "DE": "https://www.kobo.com/{{region}}/{{language}}/p/gratis-ebooks", + "NL": "https://www.kobo.com/{{region}}/{{language}}/p/gratis-ebooks", + "IT": "https://www.kobo.com/{{region}}/{{language}}/p/libri-gratuiti", + "ES": "https://www.kobo.com/{{region}}/{{language}}/p/libros-gratis", + "PT": "https://www.kobo.com/{{region}}/{{language}}/p/livros-gratis", + "JA": "https://www.kobo.com/{{region}}/{{language}}/p/free-ebooks", + }, + "fte_feedback": f"{store}/v1/products/ftefeedback", + "get_tests_request": f"{store}/v1/analytics/gettests", + "giftcard_epd_redeem_url": "https://www.kobo.com/{{region}}/{{language}}/redeem-ereader", + "giftcard_redeem_url": "https://www.kobo.com/{{region}}/{{language}}/redeem", + "help_page": "http://www.kobo.com/help", + "image_host": "https://cdn.kobo.com/book-images", + "image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg", + "image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg", + "kobo_audiobooks_enabled": "False", + "kobo_audiobooks_orange_deal_enabled": "False", + "kobo_audiobooks_subscriptions_enabled": "False", + "kobo_nativeborrow_enabled": "True", + "kobo_onestorelibrary_enabled": "False", + "kobo_redeem_enabled": "True", + "kobo_shelfie_enabled": "False", + "kobo_subscriptions_enabled": "False", + "kobo_superpoints_enabled": "False", + "kobo_wishlist_enabled": "True", + "library_book": f"{store}/v1/user/library/{{BookId}}", + "library_items": f"{store}/v1/user/library", + "library_metadata": f"{store}/v1/library/{{BookId}}/metadata", + "library_prices": f"{store}/v1/user/library/previews/price", + "library_search": f"{store}/v1/library/search", + "library_sync": f"{store}/v1/library/sync", + "love_dashboard_page": "https://www.kobo.com/{{region}}/{{language}}/kobosuperpoints", + "love_points_redemption_page": "https://www.kobo.com/{{region}}/{{language}}/KoboSuperPointsRedemption?productId={{ProductId}}", + "magazine_landing_page": "https://www.kobo.com/emagazines", + "notifications_registration_issue": f"{store}/v1/notifications/registration", + "oauth_host": "https://oauth.kobo.com", + "password_retrieval_page": "https://www.kobo.com/passwordretrieval.html", + "post_analytics_event": f"{store}/v1/analytics/event", + "privacy_page": "https://www.kobo.com/privacypolicy?style=onestore", + "product_nextread": f"{store}/v1/products/{{ProductId}}/nextread", + "product_prices": f"{store}/v1/products/{{ProductId}}/prices", + "product_recommendations": f"{store}/v1/products/{{ProductId}}/recommendations", + "product_reviews": f"{store}/v1/products/{{ProductIds}}/reviews", + "products": f"{store}/v1/products", + "provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{{ProviderName}}?returnUrl=http://store.kobobooks.com", + "purchase_buy_templated": "https://www.kobo.com/{{region}}/{{language}}/checkoutoption/{{ProductId}}", + "quickbuy_checkout": f"{store}/v1/store/quickbuy/{{PurchaseId}}/checkout", + "quickbuy_create": f"{store}/v1/store/quickbuy", + "rating": f"{store}/v1/products/{{ProductId}}/rating/{{Rating}}", + "reading_services_host": "https://readingservices.kobo.com", + "reading_state": f"{store}/v1/library/{{BookId}}/state", + "registration_page": "https://authorize.kobo.com/signup?returnUrl=http://store.kobobooks.com", + "related_items": f"{store}/v1/products/{{Id}}/related", + "remaining_book_series": f"{store}/v1/products/books/series/{{SeriesId}}", + "rename_tag": f"{store}/v1/library/tags/{{TagId}}", + "review": f"{store}/v1/products/reviews/{{ReviewId}}", + "review_sentiment": f"{store}/v1/products/reviews/{{ReviewId}}/sentiment/{{Sentiment}}", + "shelfie_recommendations": f"{store}/v1/user/recommendations/shelfie", + "sign_in_page": "https://authorize.kobo.com/signin?returnUrl=http://store.kobobooks.com", + "social_authorization_host": "https://social.kobobooks.com/soc498", + "social_host": "https://social.kobobooks.com", + "store_home": "www.kobo.com/{{region}}/{{language}}", + "store_host": "www.kobo.com", + "store_newreleases": "https://www.kobo.com/{{region}}/{{language}}/List/new-releases/702l7BQRF0qxkBhIjFyniQ", + "store_search": "https://www.kobo.com/{{region}}/{{language}}/Search?Query={{query}}", + "store_top50": "https://www.kobo.com/{{region}}/{{language}}/List/top-50/aZzhelcQ71qJjlYulfCpEQ", + "tag_items": f"{store}/v1/library/tags/{{TagId}}/Items", + "tags": f"{store}/v1/library/tags", + "taste_profile": f"{store}/v1/products/tasteprofile", + "update_accessibility_to_preview": f"{store}/v1/library/{{BookId}}/preview", + "use_one_store": "False", + "user_loyalty_benefits": f"{store}/v1/user/loyalty/benefits", + "user_platform": f"{store}/v1/user/platform", + "user_profile": f"{store}/v1/user/profile", + "user_ratings": f"{store}/v1/user/ratings", + "user_recommendations": f"{store}/v1/user/recommendations", + "user_reviews": f"{store}/v1/user/reviews", + "user_wishlist": f"{store}/v1/user/wishlist", + "userguide_host": "https://ereaderfiles.kobo.com", + "wishlist_page": "https://www.kobo.com/{{region}}/{{language}}/account/wishlist", + } + + +# ── Library Sync ──────────────────────────────────────── + + +def _build_entitlement(book_id: str, kepub_path: str, download_url: str) -> dict: + """构建 Kobo NewEntitlement 响应对象。 + + 格式参考 calibre-web cps/kobo.py get_metadata()。 + 所有 UUID 字段使用同一个 book_id 保持一致。 + """ + now = _utc_now() + file_size = 0 + path = Path(kepub_path) + if path.exists(): + file_size = path.stat().st_size + + # 从文件名推断标题 + title = path.stem.replace(".kepub", "") + + return { + "NewEntitlement": { + "BookEntitlement": { + "Accessibility": "Full", + "ActivePeriod": {"From": now}, + "Created": now, + "CrossRevisionId": book_id, + "Id": book_id, + "IsHiddenFromArchive": False, + "IsLocked": False, + "IsRemoved": False, + "LastModified": now, + "OriginCategory": "Imported", + "RevisionId": book_id, + "Status": "Active", + }, + "BookMetadata": { + "Categories": ["00000000-0000-0000-0000-000000000001"], + "ContributorRoles": [{"Name": "kobo-manga"}], + "Contributors": ["kobo-manga"], + "CoverImageId": book_id, + "CrossRevisionId": book_id, + "CurrentDisplayPrice": {"CurrencyCode": "USD", "TotalAmount": 0}, + "CurrentLoveDisplayPrice": {"TotalAmount": 0}, + "Description": "", + # Format 必须是 EPUB3FL(不能写 KEPUB),因为我们打的 + # 是 pre-paginated EPUB3 固定布局漫画。calibre-web 在 + # cps/kobo.py:445-459 get_metadata() 里有这段分支: + # + # for kobo_format in KOBO_FORMATS[book_data.format]: + # if get_epub_layout(book, book_data) == 'pre-paginated': + # kobo_format = 'EPUB3FL' + # download_urls.append({ + # "Format": kobo_format, + # "Size": book_data.uncompressed_size, + # "Url": get_download_url_for_book(...), + # "Platform": "Generic", + # }) + # + # 如果写成 KEPUB,Kobo 固件 mime 校验失败,会把 entitlement + # 写入 content 表但 DownloadUrl 字段被填成字面字符串 "true", + # 永远不会触发实际下载,也不会进 SyncQueue。 + # URL 路径里的 format segment 仍是 'kepub' 小写 —— calibre-web + # 也是这样:传给 get_download_url_for_book 的是 book_data.format + # 原始值,不是 kobo_format。 + "DownloadUrls": [ + { + "Format": "EPUB3FL", + "Size": file_size, + "Url": download_url, + "Platform": "Generic", + } + ], + "EntitlementId": book_id, + "ExternalIds": [], + "Genre": "00000000-0000-0000-0000-000000000001", + "IsEligibleForKoboLove": False, + "IsInternetArchive": False, + "IsPreOrder": False, + "IsSocialEnabled": True, + "Language": "zh", + "PhoneticPronunciations": {}, + "PublicationDate": now[:10] + "T00:00:00Z", + "Publisher": {"Imprint": "", "Name": "kobo-manga"}, + "RevisionId": book_id, + "Title": title, + "WorkId": book_id, + }, + "ReadingState": { + "Created": now, + "CurrentBookmark": {"LastModified": now}, + "EntitlementId": book_id, + "LastModified": now, + "PriorityTimestamp": now, + "Statistics": {"LastModified": now}, + "StatusInfo": { + "LastModified": now, + "Status": "ReadyToRead", + "TimesStartedReading": 0, + }, + }, + } + } + + +@router.get("/v1/library/sync") +async def library_sync( + request: Request, + auth_token: str, + db: Database = Depends(_get_db), +): + """返回待同步书籍列表。 + + Kobo 设备定期调用此端点检查新书。 + 返回 NewEntitlement 列表,设备会自动下载。 + """ + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + + base_url = str(request.base_url).rstrip("/") + pending = get_pending_syncs(db, device["device_id"]) + + entitlements = [] + for item in pending: + download_url = ( + f"{base_url}/kobo/{auth_token}/download/" + f"{item['book_id']}/kepub" + ) + entitlement = _build_entitlement( + item["book_id"], item["kepub_path"], download_url + ) + entitlements.append(entitlement) + # 不在这里 mark_synced:Kobo 的下载流程是 + # sync → metadata → download + # 三步异步进行,每一步可能在不同的 HTTP 连接里。如果在 sync + # 阶段就 mark_synced,下次 get_pending_syncs 会返回空,那本书 + # 永远不会出现在后续 sync 响应里。Kobo 一旦在某次 sync 中收到 + # 错误的 entitlement(比如 Format 字段错),就会把它写进 content + # 表的"中毒"行(DownloadUrl='true'),而后续 sync 因为返回空而 + # 没机会用修正后的 entitlement 覆盖它。 + # mark_synced 移到 download_book 端点(line ~430),等设备 + # 真的拉到 KEPUB 文件之后才标记。 + # calibre-web 用 SyncToken 时间戳做 delta 同步(cps/kobo.py + # SyncToken.from_headers / generate_sync_response),我们没 + # 实现 sync_token delta,副作用是同一本未下载的书会在每次 + # sync 都重复返回,但 Kobo 客户端按 RevisionId 去重,不会 + # 重复加进库。 + + update_device_sync_time(db, device["device_id"]) + + logger.info( + "Kobo sync: 设备 %s, %d 本待同步", + device["device_name"], len(entitlements), + ) + + # sync token 格式必须和 calibre-web 一致,包含 version 字段 + # Kobo 固件依赖这个格式来决定是否处理 DownloadUrls + import time + now_ts = time.time() + sync_token_data = { + "data": { + "archive_last_modified": -62135596800.0, + "books_last_created": -62135596800.0, + "books_last_modified": -62135596800.0, + "raw_kobo_store_token": "", + "reading_state_last_modified": now_ts, + "tags_last_modified": -62135596800.0, + }, + "version": "1-1-0", + } + sync_token = base64.b64encode( + json_mod.dumps(sync_token_data).encode() + ).decode() + + headers = { + "x-kobo-synctoken": sync_token, + "x-kobo-apitoken": "e30=", + } + + return Response( + content=json_mod.dumps(entitlements, ensure_ascii=False), + media_type="application/json", + headers=headers, + ) + + +# ── Download ──────────────────────────────────────────── + + +@router.get("/download/{book_id}/{download_format}") +async def download_book_short( + auth_token: str, + book_id: str, + download_format: str, + db: Database = Depends(_get_db), +): + """简短路径下载(calibre-web 兼容格式)。""" + return await download_book(auth_token, book_id, download_format, db) + + +@router.get("/v1/library/{book_id}/download/{download_format}") +async def download_book( + auth_token: str, + book_id: str, + download_format: str, + db: Database = Depends(_get_db), +): + """提供 KEPUB 文件下载。 + + Kobo 的下载流程:sync → metadata → download。设备只有在前两步 + 都成功并把书加入 SyncQueue 后,才会用 DownloadUrls[].Url 拉取 + 实际文件。 + + mark_synced 必须在这个端点而不是 library_sync 里调用,原因见 + library_sync 内部注释。calibre-web 用 sync_token 时间戳做 delta, + 不需要显式 mark;我们没实现 sync_token delta,所以用"设备真的 + 下载到文件"这个事件作为 mark_synced 的触发点。 + """ + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + + # 查找文件 + pending = get_pending_syncs(db, device["device_id"]) + target = next((p for p in pending if p["book_id"] == book_id), None) + + # 也查已同步的(设备可能重新下载) + if not target: + row = db.conn.execute( + "SELECT * FROM kobo_sync_state WHERE device_id=? AND book_id=?", + (device["device_id"], book_id), + ).fetchone() + target = dict(row) if row else None + + if not target: + return JSONResponse({"error": "Book not found"}, status_code=404) + + kepub_path = Path(target["kepub_path"]) + if not kepub_path.exists(): + logger.error("KEPUB 文件不存在: %s", kepub_path) + return JSONResponse({"error": "File not found"}, status_code=404) + + # 标记为已同步 + mark_synced(db, device["device_id"], book_id) + + logger.info("Kobo download: %s -> %s", device["device_name"], kepub_path.name) + + return FileResponse( + path=str(kepub_path), + media_type="application/epub+zip", + filename=kepub_path.name, + ) + + +# ── Stub Endpoints ────────────────────────────────────── + + +@router.put("/v1/library/{book_id}/state") +async def update_state( + auth_token: str, + book_id: str, + request: Request, + db: Database = Depends(_get_db), +): + """接收阅读进度(stub,仅返回 200)。""" + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + return JSONResponse({"result": "ok"}) + + +@router.get("/v1/library/tags") +async def get_tags( + auth_token: str, + db: Database = Depends(_get_db), +): + """返回书架标签(stub,返回空列表)。""" + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + return JSONResponse([]) + + +@router.get("/v1/library/{book_id}/thumbnail/{width}/{height}/{is_grey}/image.jpg") +async def get_thumbnail( + auth_token: str, + book_id: str, + width: int, + height: int, + is_grey: str, + db: Database = Depends(_get_db), +): + """封面缩略图:从对应 KEPUB zip 中提取 OEBPS/Images/cover.jpg。 + + Kobo 在 sync 接受 entitlement 后会立即请求两次封面(一次小尺寸 + 355x530 给列表视图,一次大尺寸 1072/1448 给详情视图)。 + 如果封面返回 404,Kobo UI 会显示空白书名占位,并且**可能阻塞后续 + 的 download 触发逻辑** —— 我们实测当封面是 404 时,metadata 端点 + 被反复重试但 download 永不发起。 + + calibre-web 在 cps/kobo.py HandleCoverImageRequest 里用 Pillow + 处理封面(resize/grayscale),我们简化处理:直接从 kepub zip 提取 + OEBPS/Images/cover.jpg 然后按 Kobo 请求的尺寸 thumbnail。 + is_grey 参数我们暂时忽略(Kobo 自己会做 e-ink 灰度转换)。 + """ + import io + import zipfile + + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + + # 找到这本书对应的 kepub 路径(pending 或已 synced 都查) + row = db.conn.execute( + "SELECT kepub_path FROM kobo_sync_state WHERE device_id=? AND book_id=?", + (device["device_id"], book_id), + ).fetchone() + if not row: + return Response(status_code=404) + + kepub_path = Path(row["kepub_path"]) + if not kepub_path.exists(): + return Response(status_code=404) + + try: + with zipfile.ZipFile(kepub_path, "r") as zf: + # 优先 cover.jpg,回退第一张图片 + names = zf.namelist() + cover_name = next( + (n for n in names if n.endswith("OEBPS/Images/cover.jpg")), + None, + ) + if cover_name is None: + cover_name = next( + (n for n in names if "Images/" in n and n.lower().endswith( + (".jpg", ".jpeg", ".png", ".webp") + )), + None, + ) + if cover_name is None: + return Response(status_code=404) + data = zf.read(cover_name) + except (zipfile.BadZipFile, KeyError, OSError) as e: + logger.warning("读取封面失败 %s: %s", kepub_path, e) + return Response(status_code=404) + + # 按 Kobo 请求的尺寸缩放(保持比例) + try: + from PIL import Image + img = Image.open(io.BytesIO(data)) + img.thumbnail((width, height), Image.LANCZOS) + if img.mode != "RGB": + img = img.convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=85) + data = buf.getvalue() + except Exception as e: + logger.warning("缩放封面失败 %s: %s(返回原图)", kepub_path, e) + + return Response(content=data, media_type="image/jpeg") + + +# Kobo 设备可能请求的其他端点,返回空响应避免无限重试 +@router.get("/v1/library/{book_id}") +async def get_book_entitlement( + auth_token: str, + book_id: str, + db: Database = Depends(_get_db), +): + """单本书的 entitlement(stub)。""" + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + return JSONResponse({}) + + +@router.get("/v1/library/{book_id}/content") +async def get_content_access( + auth_token: str, + book_id: str, + db: Database = Depends(_get_db), +): + """内容访问权限(stub)。""" + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + return JSONResponse({}) + + +# ── Kobo 官方 API 兼容 Stubs ────────────────────────────── +# Kobo 设备会请求这些端点,即使 initialization 里指向了官方服务器。 +# 返回合理的空响应避免 404 和重试。 + + +@router.get("/v1/user/wishlist") +async def get_wishlist(auth_token: str): + """用户愿望清单(stub)。""" + return JSONResponse({"Items": [], "TotalCount": 0}) + + +@router.get("/v1/user/recommendations") +async def get_recommendations(auth_token: str): + """用户推荐(stub)。""" + return JSONResponse([]) + + +@router.get("/v1/user/profile") +async def get_user_profile(auth_token: str): + """用户资料(stub)。""" + return JSONResponse({}) + + +@router.get("/v1/user/loyalty/benefits") +async def get_loyalty_benefits(auth_token: str): + """会员权益(stub)。""" + return JSONResponse({"Benefits": {}}) + + +@router.get("/v1/deals") +async def get_deals(auth_token: str): + """促销信息(stub)。""" + return JSONResponse([]) + + +@router.post("/v1/analytics/event") +async def analytics_event(auth_token: str, request: Request): + """分析事件(stub,接收但忽略)。""" + return JSONResponse({"result": "ok"}) + + +@router.post("/v1/analytics/gettests") +async def analytics_gettests(auth_token: str, request: Request): + """A/B 测试配置(stub)。""" + return JSONResponse({"Results": []}) + + +@router.get("/v1/products/{product_id}/nextread") +async def get_nextread(auth_token: str, product_id: str): + """下一本推荐(stub)。""" + return JSONResponse([]) + + +@router.get("/v1/library/{book_id}/metadata") +async def get_book_metadata( + request: Request, + auth_token: str, + book_id: str, + db: Database = Depends(_get_db), +): + """书籍元数据(Kobo 下载前请求)。""" + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + + # 从同步队列找到对应书籍 + pending = get_pending_syncs(db, device["device_id"]) + target = next((p for p in pending if p["book_id"] == book_id), None) + if not target: + row = db.conn.execute( + "SELECT * FROM kobo_sync_state WHERE device_id=? AND book_id=?", + (device["device_id"], book_id), + ).fetchone() + target = dict(row) if row else None + + if not target: + return JSONResponse({}, status_code=404) + + base_url = str(request.base_url).rstrip("/") + download_url = f"{base_url}/kobo/{auth_token}/download/{book_id}/kepub" + path = Path(target["kepub_path"]) + title = path.stem.replace(".kepub", "") + file_size = path.stat().st_size if path.exists() else 0 + now = _utc_now() + + # 关键:返回必须是 [metadata] 裸 JSON 数组(长度 1), + # 不是裸 dict 也不是 {"BookMetadata": ...} 包装对象。 + # + # calibre-web cps/kobo.py:336-351 HandleMetadataRequest 原文: + # + # @kobo.route("/v1/library//metadata") + # @requires_kobo_auth + # @download_required + # def HandleMetadataRequest(book_uuid): + # ... + # metadata = get_metadata(book) + # response = make_response(json.dumps([metadata], ensure_ascii=False)) + # response.headers["Content-Type"] = "application/json; charset=utf-8" + # return response + # + # 注意 json.dumps([metadata], ...) 把单个 metadata 包成长度 1 的数组。 + # 如果返回裸 dict,Kobo parser 会把它当成失败响应反复重试 metadata + # 端点(实测日志里同一 book_id 的 metadata 被连续 GET 两次以上), + # 永远不会进入 download 阶段。 + # + # Content-Type 也必须是 'application/json; charset=utf-8'(带 charset + # 后缀),跟 calibre-web 一致。FastAPI 的 JSONResponse 默认是 + # 'application/json' 不带 charset,所以这里改用底层 Response 手动 + # dump + 设 media_type。 + metadata = { + "Categories": ["00000000-0000-0000-0000-000000000001"], + "CoverImageId": book_id, + "CrossRevisionId": book_id, + "CurrentDisplayPrice": {"CurrencyCode": "USD", "TotalAmount": 0}, + "CurrentLoveDisplayPrice": {"TotalAmount": 0}, + "Description": "", + "DownloadUrls": [{ + "Format": "EPUB3FL", + "Size": file_size, + "Url": download_url, + "Platform": "Generic", + }], + "EntitlementId": book_id, + "ExternalIds": [], + "Genre": "00000000-0000-0000-0000-000000000001", + "IsEligibleForKoboLove": False, + "IsInternetArchive": False, + "IsPreOrder": False, + "IsSocialEnabled": True, + "Language": "zh", + "PhoneticPronunciations": {}, + "PublicationDate": now[:10] + "T00:00:00Z", + "Publisher": {"Imprint": "", "Name": "kobo-manga"}, + "RevisionId": book_id, + "Title": title, + "WorkId": book_id, + "ContributorRoles": [{"Name": "kobo-manga"}], + "Contributors": ["kobo-manga"], + } + return Response( + content=json_mod.dumps([metadata], ensure_ascii=False), + media_type="application/json; charset=utf-8", + ) + + +# ── Download Keys/Link(Kobo 下载管理器使用)────────── + + +@router.post("/v1/library/downloadkeys") +async def get_download_keys( + request: Request, + auth_token: str, + db: Database = Depends(_get_db), +): + """返回下载密钥。Kobo 下载管理器在下载前可能调用此端点。""" + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + + body = await request.json() + logger.info("downloadkeys request: %s", body) + + # 返回空密钥列表(无 DRM) + return JSONResponse({"Items": []}) + + +@router.get("/v1/library/downloadlink") +@router.post("/v1/library/downloadlink") +async def get_download_link( + request: Request, + auth_token: str, + db: Database = Depends(_get_db), +): + """返回实际下载链接。Kobo 下载管理器使用此端点获取文件 URL。""" + device = _validate_token(auth_token, db) + if not device: + return JSONResponse({"error": "Invalid token"}, status_code=401) + + base_url = str(request.base_url).rstrip("/") + + # 尝试从 query params 或 body 获取 book_id + book_id = request.query_params.get("BookId", "") + if not book_id: + try: + body = await request.json() + book_id = body.get("BookId", "") + logger.info("downloadlink request body: %s", body) + except Exception: + pass + + logger.info("downloadlink request for book: %s", book_id) + + if book_id: + download_url = f"{base_url}/kobo/{auth_token}/download/{book_id}/kepub" + return JSONResponse({ + "Urls": [{ + "Format": "KEPUB", + "Url": download_url, + "Platform": "Generic", + }] + }) + + # 返回所有待同步书的下载链接 + pending = get_pending_syncs(db, device["device_id"]) + urls = [] + for item in pending: + urls.append({ + "BookId": item["book_id"], + "Urls": [{ + "Format": "KEPUB", + "Url": f"{base_url}/kobo/{auth_token}/download/{item['book_id']}/kepub", + "Platform": "Generic", + }] + }) + return JSONResponse({"Items": urls}) + + +# ── Catch-all(调试用,捕获所有未匹配的请求)────────── + + +@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) +async def catch_all(auth_token: str, path: str, request: Request): + """捕获所有未匹配的 Kobo 请求(调试用)。""" + logger.warning("UNHANDLED: %s %s", request.method, request.url) + return JSONResponse({}) diff --git a/src/kobo_manga/web/static/style.css b/src/kobo_manga/web/static/style.css new file mode 100644 index 0000000000000000000000000000000000000000..4731de0714e5d232ffcb547d94288575fe0686c4 --- /dev/null +++ b/src/kobo_manga/web/static/style.css @@ -0,0 +1,294 @@ +/* kobo-manga Web UI — 响应式 CSS */ + +:root { + --bg: #fafafa; + --fg: #1a1a1a; + --muted: #666; + --border: #ddd; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --success: #16a34a; + --error: #dc2626; + --warning: #d97706; + --card-bg: #fff; + --radius: 6px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.6; + max-width: 960px; + margin: 0 auto; + padding: 0 1rem; +} + +/* ── Header ── */ + +header { + border-bottom: 1px solid var(--border); + padding: 0.75rem 0; + margin-bottom: 1.5rem; +} + +nav { + display: flex; + gap: 1.5rem; + align-items: center; +} + +nav a { color: var(--fg); text-decoration: none; } +nav a:hover { color: var(--accent); } +nav .logo { font-weight: 700; font-size: 1.1rem; } + +/* ── Main ── */ + +main { min-height: 60vh; } + +h1 { margin-bottom: 1rem; font-size: 1.5rem; } +h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; } + +a { color: var(--accent); } +.muted { color: var(--muted); } +.error { color: var(--error); } +.success { color: var(--success); } + +/* ── Search ── */ + +.search-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.search-bar input { + flex: 1; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 1rem; +} + +button, .btn-cancel { + padding: 0.6rem 1.2rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.9rem; +} + +button:hover { background: var(--accent-hover); } + +.btn-cancel { + background: transparent; + color: var(--error); + border: 1px solid var(--error); + padding: 0.3rem 0.8rem; + font-size: 0.8rem; +} + +.btn-cancel:hover { background: var(--error); color: #fff; } + +/* ── Cards ── */ + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; +} + +.card { + display: block; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + text-decoration: none; + color: var(--fg); + transition: box-shadow 0.15s; +} + +.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); } + +.card-cover { + width: 100%; + aspect-ratio: 3/4; + object-fit: cover; + display: block; + background: var(--border); +} + +.card-cover.placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 0.8rem; +} + +.card-info { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.85rem; +} + +/* ── Tags ── */ + +.tag { + display: inline-block; + background: var(--border); + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-size: 0.75rem; + color: var(--muted); +} + +.tag-done { background: #dcfce7; color: var(--success); } +.tag-failed { background: #fef2f2; color: var(--error); } +.tag-cancelled { background: #f3f4f6; color: var(--muted); } +.tag-downloading { background: #dbeafe; color: var(--accent); } +.tag-pending { background: #fef9c3; color: var(--warning); } + +/* ── Manga Detail ── */ + +.manga-header { + display: flex; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.manga-cover { + width: 200px; + border-radius: var(--radius); + flex-shrink: 0; +} + +.manga-meta p { margin-bottom: 0.3rem; } + +details { margin: 1rem 0; } +details summary { cursor: pointer; color: var(--accent); } +details p { margin-top: 0.5rem; color: var(--muted); } + +/* ── Forms ── */ + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + font-weight: 600; + margin-bottom: 0.3rem; + font-size: 0.9rem; +} + +.form-group input, .form-group select { + width: 100%; + max-width: 300px; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.9rem; +} + +.hint { font-size: 0.8rem; color: var(--muted); } + +/* ── Chapter List ── */ + +.chapter-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.chapter-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0.75rem; + border-bottom: 1px solid var(--border); + font-size: 0.85rem; +} + +.chapter-item:last-child { border-bottom: none; } +.chapter-number { font-weight: 600; min-width: 3rem; } + +/* ── Tasks ── */ + +.task-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.task-item { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; +} + +.task-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.task-detail { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: var(--muted); + margin-bottom: 0.5rem; +} + +.task-progress { + font-size: 0.85rem; + margin-bottom: 0.5rem; +} + +/* ── Progress Bar ── */ + +.progress-bar { + height: 6px; + background: var(--border); + border-radius: 3px; + overflow: hidden; + margin-bottom: 0.3rem; +} + +.progress-fill { + height: 100%; + background: var(--accent); + transition: width 0.3s; + border-radius: 3px; +} + +/* ── Footer ── */ + +footer { + border-top: 1px solid var(--border); + padding: 1rem 0; + margin-top: 2rem; + text-align: center; + color: var(--muted); + font-size: 0.8rem; +} + +/* ── Responsive ── */ + +@media (max-width: 600px) { + .manga-header { flex-direction: column; } + .manga-cover { width: 100%; max-width: 200px; } + .card-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } + .task-detail { flex-direction: column; gap: 0.3rem; } +} diff --git a/src/kobo_manga/web/tasks.py b/src/kobo_manga/web/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..a1c5768d27f18a2fe64da3b3f961854cb4fcdc93 --- /dev/null +++ b/src/kobo_manga/web/tasks.py @@ -0,0 +1,168 @@ +"""后台下载任务管理""" + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import datetime +from uuid import uuid4 + +from kobo_manga.config import load_config +from kobo_manga.db.database import Database +from kobo_manga.db.queries import get_manga +from kobo_manga.models import ChapterResult +from kobo_manga.pipeline import MangaPipeline +from kobo_manga.utils import parse_chapter_range + +logger = logging.getLogger(__name__) + +# 保留最近完成的任务数 +MAX_FINISHED_TASKS = 50 + + +@dataclass +class TaskStatus: + """下载任务状态。""" + id: str + status: str # pending | downloading | converting | done | failed | cancelled + manga_title: str + source: str + chapters: str + progress: int = 0 # 0-100 + chapters_done: int = 0 + chapters_total: int = 0 + current_chapter: str | None = None + error_message: str | None = None + created_at: datetime = field(default_factory=datetime.now) + _pipeline: MangaPipeline | None = field(default=None, repr=False) + + +# 全局任务注册表 +_tasks: dict[str, TaskStatus] = {} + + +def get_all_tasks() -> list[TaskStatus]: + """获取所有任务,最新的在前。""" + return sorted(_tasks.values(), key=lambda t: t.created_at, reverse=True) + + +def get_task(task_id: str) -> TaskStatus | None: + """获取单个任务。""" + return _tasks.get(task_id) + + +def _cleanup_finished() -> None: + """清理超出上限的已完成任务。""" + finished = [ + t for t in _tasks.values() + if t.status in ("done", "failed", "cancelled") + ] + if len(finished) > MAX_FINISHED_TASKS: + finished.sort(key=lambda t: t.created_at) + for t in finished[: len(finished) - MAX_FINISHED_TASKS]: + _tasks.pop(t.id, None) + + +def create_download_task( + manga_id: str, + source: str, + chapters: str, + chapter_type: str | None = None, +) -> TaskStatus: + """创建下载任务并在后台启动。""" + task_id = uuid4().hex[:8] + task = TaskStatus( + id=task_id, + status="pending", + manga_title="...", + source=source, + chapters=chapters, + ) + _tasks[task_id] = task + + asyncio.create_task(_run_download(task, manga_id, chapters, chapter_type)) + _cleanup_finished() + return task + + +async def _run_download( + task: TaskStatus, + manga_id: str, + chapters_str: str, + chapter_type: str | None, +) -> None: + """后台下载协程。""" + try: + config = load_config() + db = Database() + db.initialize() + + try: + pipeline = MangaPipeline(config, db) + task._pipeline = pipeline + + # 从 DB 查找漫画 URL + manga = get_manga(db, manga_id, task.source) + if not manga: + task.status = "failed" + task.error_message = f"漫画不存在: {manga_id}" + return + + task.manga_title = manga.title + task.status = "downloading" + + # 解析章节范围 + chapter_range = None + chapter_ids = None + if chapters_str and chapters_str != "all": + chapter_range = parse_chapter_range(chapters_str) + + def on_chapter_progress( + index: int, total: int, result: ChapterResult + ) -> None: + task.chapters_done = index + task.chapters_total = total + task.current_chapter = result.chapter.title + task.progress = int(index / total * 100) if total > 0 else 0 + + kepub_paths = await pipeline.download_and_convert( + manga.url, + source_name=task.source, + chapter_range=chapter_range, + chapter_ids=chapter_ids, + chapter_type=chapter_type, + on_progress=on_chapter_progress, + ) + + task.status = "done" + task.progress = 100 + logger.info( + "任务 %s 完成: %d 个 KEPUB", task.id, len(kepub_paths), + ) + + finally: + db.close() + + except Exception as e: + task.status = "failed" + task.error_message = str(e) + logger.error("任务 %s 失败: %s", task.id, e) + + +def cancel_task(task_id: str) -> bool: + """取消任务。返回是否成功。""" + task = _tasks.get(task_id) + if not task or task.status not in ("pending", "downloading"): + return False + + # MangaPipeline 当前没有 cancel_download 方法:先把状态标成 cancelled, + # 后台协程下次 await 时会发现任务被取消(未来加上真正的 cancel 时再接线)。 + if task._pipeline is not None: + cancel_fn = getattr(task._pipeline, "cancel_download", None) + if callable(cancel_fn): + try: + cancel_fn() + except Exception as e: + logger.warning("cancel_download 调用失败: %s", e) + + task.status = "cancelled" + return True diff --git a/src/kobo_manga/web/templates/_search_results.html b/src/kobo_manga/web/templates/_search_results.html new file mode 100644 index 0000000000000000000000000000000000000000..bbf19f86491ca83ad5a8e622469d6b29fae6f006 --- /dev/null +++ b/src/kobo_manga/web/templates/_search_results.html @@ -0,0 +1,26 @@ +{% if error %} +
搜索出错: {{ error }}
+{% elif results is not none %} + {% if results|length == 0 %} +

未找到结果

+ {% else %} +

找到 {{ results|length }} 个结果

+ + {% endif %} +{% endif %} diff --git a/src/kobo_manga/web/templates/base.html b/src/kobo_manga/web/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..4130a2a2e1b63c150653f6dd3d1dd2cb0e906c9a --- /dev/null +++ b/src/kobo_manga/web/templates/base.html @@ -0,0 +1,32 @@ + + + + + + {% block title %}kobo-manga{% endblock %} + + + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+

kobo-manga Web UI

+
+ + + + + + diff --git a/src/kobo_manga/web/templates/manga.html b/src/kobo_manga/web/templates/manga.html new file mode 100644 index 0000000000000000000000000000000000000000..25e0746f17f215f061b10502187f792d7a72ee6c --- /dev/null +++ b/src/kobo_manga/web/templates/manga.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block title %}{{ manga.title if manga else "漫画详情" }} - kobo-manga{% endblock %} + +{% block content %} +{% if error %} +
{{ error }}
+返回搜索 + +{% elif manga %} +
+ {% if manga.cover_url %} + {{ manga.title }} + {% endif %} +
+

{{ manga.title }}

+ {% if manga.author %}

作者: {{ manga.author }}

{% endif %} + {% if manga.tags %}

标签: {{ manga.tags | join(", ") }}

{% endif %} +

来源: {{ manga.source }}

+

章节数: {{ manga.chapters | length }}

+ {% if manga.chapters %} +

范围: {{ manga.chapters[0].title }} ~ {{ manga.chapters[-1].title }}

+ {% endif %} +
+
+ +{% if manga.description %} +
+ 简介 +

{{ manga.description }}

+
+{% endif %} + +

下载

+ +
+ + + +
+ + + 输入 all 下载全部,或 1-10 指定范围 +
+ +
+ + +
+ + +
+
+ +

章节列表

+
+ {% for ch in manga.chapters %} +
+ {{ ch.chapter_number }} + {{ ch.title }} + {{ ch.chapter_type }} +
+ {% endfor %} +
+ +{% endif %} +{% endblock %} diff --git a/src/kobo_manga/web/templates/search.html b/src/kobo_manga/web/templates/search.html new file mode 100644 index 0000000000000000000000000000000000000000..c996d7bec9dadca14875724afa610ca90ffd7e90 --- /dev/null +++ b/src/kobo_manga/web/templates/search.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}搜索 - kobo-manga{% endblock %} + +{% block content %} +

搜索漫画

+ +
+ +
+ +
+{% include "_search_results.html" %} +
+{% endblock %} diff --git a/src/kobo_manga/web/templates/tasks.html b/src/kobo_manga/web/templates/tasks.html new file mode 100644 index 0000000000000000000000000000000000000000..763175f394dfbe0120c631e741d9ff8d18b03914 --- /dev/null +++ b/src/kobo_manga/web/templates/tasks.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %}任务 - kobo-manga{% endblock %} + +{% block content %} +

下载任务

+ +{% if not tasks %} +

暂无任务。去搜索漫画

+{% else %} + + +{% if has_active %} + +{% endif %} + +
+ {% for task in tasks %} +
+ +
+ {{ task.manga_title }} + {{ task.status }} +
+ +
+ 来源: {{ task.source }} + 章节: {{ task.chapters }} + 创建: {{ task.created_at.strftime('%H:%M:%S') }} +
+ +
+ {% if task.status == 'downloading' %} +
+
+
+ {{ task.chapters_done }}/{{ task.chapters_total }} 章节 + {% if task.current_chapter %}— {{ task.current_chapter }}{% endif %} + + {% elif task.status == 'done' %} + 下载完成 + {% elif task.status == 'failed' %} + {{ task.error_message or "下载失败" }} + {% elif task.status == 'cancelled' %} + 已取消 + {% elif task.status == 'pending' %} + 等待中... + {% endif %} +
+ + {% if task.status in ('pending', 'downloading') %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+{% endif %} +{% endblock %}