@@ 57,6 57,24 @@ CREATE TABLE IF NOT EXISTS page (
CREATE INDEX IF NOT EXISTS idx_page_chapter ON page(chapter_id, chapter_source);
+CREATE TABLE IF NOT EXISTS kobo_devices (
+ device_id TEXT PRIMARY KEY,
+ auth_token TEXT UNIQUE NOT NULL,
+ device_name TEXT,
+ last_sync_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS kobo_sync_state (
+ device_id TEXT NOT NULL,
+ book_id TEXT NOT NULL,
+ kepub_path TEXT NOT NULL,
+ sync_status TEXT NOT NULL DEFAULT 'pending',
+ synced_at TEXT,
+ PRIMARY KEY (device_id, book_id),
+ FOREIGN KEY (device_id) REFERENCES kobo_devices(device_id)
+);
+
CREATE TABLE IF NOT EXISTS subscription (
manga_id TEXT NOT NULL,
source TEXT NOT NULL,
@@ 96,6 114,9 @@ class Database:
"ALTER TABLE chapter ADD COLUMN chapter_type TEXT NOT NULL DEFAULT 'chapter'"
)
+ # kobo_devices / kobo_sync_state 表(已在 SCHEMA_SQL 中 CREATE IF NOT EXISTS)
+ # 旧数据库通过 executescript(SCHEMA_SQL) 自动创建,无需额外迁移
+
def close(self) -> None:
if self.conn:
self.conn.close()
@@ 4,11 4,20 @@
"""
import json
+import uuid
from kobo_manga.db.database import Database
from kobo_manga.models import Chapter, MangaInfo, PageImage
+def make_book_id(kepub_path_or_name: str) -> str:
+ """从 KEPUB 文件路径/名称生成 UUID 格式的 book_id。
+
+ 使用 UUID5 (SHA-1 based) 确保确定性且符合 Kobo 期望的 UUID 格式。
+ """
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, kepub_path_or_name))
+
+
# ── Manga ─────────────────────────────────────────────────
def upsert_manga(db: Database, manga: MangaInfo) -> None:
@@ 343,6 352,111 @@ def list_subscriptions(db: Database) -> list[dict]:
return [dict(row) for row in rows]
+# ── Kobo Sync ───────────────────────────────────────────
+
+
+def register_device(
+ db: Database, auth_token: str, device_name: str = ""
+) -> str:
+ """注册 Kobo 设备,返回 device_id。"""
+ device_id = uuid.uuid4().hex[:16]
+ db.conn.execute(
+ "INSERT INTO kobo_devices (device_id, auth_token, device_name) VALUES (?, ?, ?)",
+ (device_id, auth_token, device_name or f"Kobo-{device_id[:6]}"),
+ )
+ db.conn.commit()
+ return device_id
+
+
+def get_device_by_token(db: Database, auth_token: str) -> dict | None:
+ """通过 auth_token 获取设备信息。"""
+ row = db.conn.execute(
+ "SELECT * FROM kobo_devices WHERE auth_token=?",
+ (auth_token,),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def list_devices(db: Database) -> list[dict]:
+ """列出所有已配对设备。"""
+ rows = db.conn.execute(
+ "SELECT * FROM kobo_devices ORDER BY created_at DESC"
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+
+def enqueue_for_sync(
+ db: Database, book_id: str, kepub_path: str, device_id: str | None = None
+) -> None:
+ """将 KEPUB 加入同步队列。device_id=None 时为所有已注册设备添加。"""
+ if device_id:
+ db.conn.execute(
+ "INSERT OR IGNORE INTO kobo_sync_state (device_id, book_id, kepub_path) "
+ "VALUES (?, ?, ?)",
+ (device_id, book_id, kepub_path),
+ )
+ else:
+ devices = list_devices(db)
+ for dev in devices:
+ db.conn.execute(
+ "INSERT OR IGNORE INTO kobo_sync_state (device_id, book_id, kepub_path) "
+ "VALUES (?, ?, ?)",
+ (dev["device_id"], book_id, kepub_path),
+ )
+ db.conn.commit()
+
+
+def get_pending_syncs(db: Database, device_id: str) -> list[dict]:
+ """获取设备的待同步书籍。"""
+ rows = db.conn.execute(
+ "SELECT * FROM kobo_sync_state WHERE device_id=? AND sync_status='pending' "
+ "ORDER BY rowid",
+ (device_id,),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+
+def mark_synced(db: Database, device_id: str, book_id: str) -> None:
+ """标记书籍已同步到设备。"""
+ db.conn.execute(
+ "UPDATE kobo_sync_state SET sync_status='synced', synced_at=datetime('now') "
+ "WHERE device_id=? AND book_id=?",
+ (device_id, book_id),
+ )
+ db.conn.commit()
+
+
+def update_device_sync_time(db: Database, device_id: str) -> None:
+ """更新设备最后同步时间。"""
+ db.conn.execute(
+ "UPDATE kobo_devices SET last_sync_at=datetime('now') WHERE device_id=?",
+ (device_id,),
+ )
+ db.conn.commit()
+
+
+def backfill_existing_kepubs(
+ db: Database, device_id: str, output_dir: "Path"
+) -> int:
+ """扫描 output/ 目录,将已有 KEPUB 文件加入设备同步队列。返回添加数量。"""
+ from pathlib import Path
+ output_dir = Path(output_dir)
+ if not output_dir.exists():
+ return 0
+
+ count = 0
+ for kepub_path in output_dir.rglob("*.kepub.epub"):
+ book_id = make_book_id(kepub_path.stem)
+ db.conn.execute(
+ "INSERT OR IGNORE INTO kobo_sync_state (device_id, book_id, kepub_path) "
+ "VALUES (?, ?, ?)",
+ (device_id, book_id, str(kepub_path)),
+ )
+ count += 1
+ db.conn.commit()
+ return count
+
+
def update_subscription_checked(
db: Database,
manga_id: str,