~cytrogen/kobo-manga

d07d4ccdc4c1ff15ba65bd8adc7fc3f5dbef7a13 — HallowDem 10 days ago 46071cd
feat(db): add kobo_devices / kobo_sync_state tables and queries

- kobo_devices: auth_token -> device_id mapping for Kobo sync protocol
- kobo_sync_state: per-device pending/synced book queue
- make_book_id: deterministic UUID5 from KEPUB filename (Kobo format)
- register_device / enqueue_for_sync / get_pending_syncs / mark_synced
- backfill_existing_kepubs: scan output dir and queue into new device
2 files changed, 135 insertions(+), 0 deletions(-)

M src/kobo_manga/db/database.py
M src/kobo_manga/db/queries.py
M src/kobo_manga/db/database.py => src/kobo_manga/db/database.py +21 -0
@@ 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()

M src/kobo_manga/db/queries.py => src/kobo_manga/db/queries.py +114 -0
@@ 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,