diff --git a/docs/ROADMAP-FUTURE.md b/docs/ROADMAP-FUTURE.md index ce6ecef..85c429a 100644 --- a/docs/ROADMAP-FUTURE.md +++ b/docs/ROADMAP-FUTURE.md @@ -60,7 +60,12 @@ your real library" before adding features. 1. **More read-only sources, same guardrails** — Readest progress, Kobo's native `KoboReader.sqlite`, Calibre-Web read-state, sideloaded EPUB/PDF. Each is a new - adapter behind the existing `unify` join. + adapter behind the existing `unify` join. **Kobo native (`ingest/kobo.py`) + done** — snapshot-first `KoboReader.sqlite` reader over the `content` table + (`ContentType = 6`, chapter-row dedup, schema-drift tolerant), merged through + the existing `unify` join with zero changes to `ingest/unify.py`; wired into + `ingest/config.py` (`[kobo]` / `STACKS_KOBO_DB`) and `ingest/refresh.py`. + Readest, Calibre-Web read-state, and sideloaded EPUB/PDF remain open. 2. **Annotations & highlights** — surface a private, searchable "commonplace book" from KOReader highlights; never synced anywhere. 3. **Series & TBR intelligence** — "next in a series you own," progress through a diff --git a/docs/audits/coverage.xml b/docs/audits/coverage.xml index 13ba0dc..921bcec 100644 --- a/docs/audits/coverage.xml +++ b/docs/audits/coverage.xml @@ -1,14 +1,14 @@ - + - /private/tmp/qts-pr27/app - /private/tmp/qts-pr27/ingest - /private/tmp/qts-pr27/recommender + /private/tmp/qts-pr29/app + /private/tmp/qts-pr29/ingest + /private/tmp/qts-pr29/recommender - + @@ -569,7 +569,7 @@ - + @@ -583,9 +583,8 @@ - - + @@ -601,64 +600,67 @@ + - + - + - + - - - - - + + - + + + - + - - - + + - + - + - - + + + - - - + + - - - + + + - - + + - - - + + - - + + + + + + + @@ -1299,6 +1301,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1882,7 +1954,7 @@ - + @@ -1900,62 +1972,62 @@ - + - + - + - + - - - - - + + + + + - + - - + + - + - + - - + + - - - - - - - - - - - + + + + + + + + + + + - - - + + + - - + + @@ -1964,87 +2036,91 @@ - - - + + + - - - - + + + - - - + + - - + + - - + + - - + + + + - - - + + - - - - - - - - - - - + + + + + + + + + + - + - - - + - + - - - - - + + + + + + + + + + - - - - - - - - + + + + + + - + + + + + + diff --git a/ingest/config.py b/ingest/config.py index 73fbe9d..88dd554 100644 --- a/ingest/config.py +++ b/ingest/config.py @@ -1,4 +1,4 @@ -"""Runtime configuration — where the real Calibre/KOReader libraries live. +"""Runtime configuration — where the real Calibre/KOReader/Kobo libraries live. Resolution order (later overrides earlier): @@ -38,6 +38,7 @@ "STACKS_DEMO", "STACKS_CALIBRE_DB", "STACKS_KOREADER_DB", + "STACKS_KOBO_DB", "STACKS_KOSYNC_HOST", "STACKS_KOSYNC_USER", "STACKS_KOSYNC_KEY", @@ -67,6 +68,7 @@ class Config: kosync_user: Optional[str] kosync_key: Optional[str] # md5 key, from the environment only demo: bool + kobo_db: Optional[Path] = None # Kobo's native KoboReader.sqlite (read-only source) aperture_strength: float = 0.0 # boost-only discovery-widening lens (0 = off) embeddings_enabled: bool = False # optional, strictly-local semantic signal dnf_signals: bool = False # opt-in soft down-weighting of stalled themes @@ -93,7 +95,9 @@ def kosync_configured(self) -> bool: @property def has_real_sources(self) -> bool: - return self.calibre_db is not None or self.koreader_db is not None + return ( + self.calibre_db is not None or self.koreader_db is not None or self.kobo_db is not None + ) def _read_toml(path: Path) -> dict[str, object]: @@ -124,6 +128,7 @@ def load_config( toml = _read_toml(path) calibre = _section(toml, "calibre") koreader = _section(toml, "koreader") + kobo = _section(toml, "kobo") kosync = _section(toml, "kosync") storage = _section(toml, "storage") @@ -163,6 +168,7 @@ def pick_int(env_key: str, key: str) -> int: return Config( calibre_db=_opt_path(pick("STACKS_CALIBRE_DB", calibre, "path")), koreader_db=_opt_path(pick("STACKS_KOREADER_DB", koreader, "path")), + kobo_db=_opt_path(pick("STACKS_KOBO_DB", kobo, "path")), data_dir=data_dir, kosync_host=pick("STACKS_KOSYNC_HOST", kosync, "host"), kosync_user=pick("STACKS_KOSYNC_USER", kosync, "user"), diff --git a/ingest/kobo.py b/ingest/kobo.py new file mode 100644 index 0000000..177ffd0 --- /dev/null +++ b/ingest/kobo.py @@ -0,0 +1,149 @@ +"""Read per-book reading statistics from Kobo's native ``KoboReader.sqlite``. + +Strictly read-only (see :mod:`ingest.snapshot`). Kobo stores per-book state in a +single ``content`` table that mixes one row per book *and* one row per chapter +of that book. Top-level "book" rows carry ``ContentType = 6``; chapter rows can +also carry ``ContentType = 6`` on some firmware versions but point back at their +parent via ``BookID``. :func:`_top_level_rows` keeps only the volume row per +book — the one whose ``BookID`` is empty or equal to its own ``ContentID``. + +There is no per-session log in ``content`` (unlike KOReader's +``page_stat_data``), so :attr:`~ingest.models.ReadingStat.sessions` is always +``0`` here — an honest "unknown", not a guess. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + +from ingest.models import ReadingStat +from ingest.snapshot import columns, open_snapshot, table_exists + +#: Kobo's ContentType for a top-level book/volume (chapters use other values, +#: though some firmware reuses 6 for chapters too — see module docstring). +BOOK_CONTENT_TYPE = 6 + +#: Kobo's ReadStatus: 0 = unread, 1 = reading, 2 = finished. +READ_STATUS_FINISHED = 2 + + +def _normalize_authors(raw: str) -> tuple[str, ...]: + """Kobo's ``Attribution`` is a single free-text, comma-separated author string.""" + parts = [p.strip() for p in raw.split(",")] + return tuple(p for p in parts if p) + + +def _parse_timestamp(raw: str) -> int: + """Parse Kobo's ISO-8601 ``DateLastRead`` into unix seconds; ``0`` if unparsable.""" + text = raw.strip() + if not text: + return 0 + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(text) + except ValueError: + return 0 + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return int(dt.timestamp()) + + +def _top_level_rows(conn: sqlite3.Connection, cols: frozenset[str]) -> list[sqlite3.Row]: + """Select ``ContentType = 6`` rows, deduplicated to one row per book volume. + + Kobo's ``content`` table stores one row per chapter alongside the top-level + volume row for the same book. The volume row is identified by ``BookID`` + being empty/NULL or equal to its own ``ContentID`` (a chapter row's + ``BookID`` points at its parent volume's ``ContentID`` instead). + """ + rows = list( + conn.execute( + "SELECT * FROM content WHERE ContentType = ? ORDER BY rowid", + (BOOK_CONTENT_TYPE,), + ).fetchall() + ) + if "MimeType" in cols: + rows = [r for r in rows if r["MimeType"]] + if "ContentID" not in cols: + return rows + grouped: dict[str, sqlite3.Row] = {} + for r in rows: + content_id = str(r["ContentID"]) + book_id = r["BookID"] if "BookID" in cols else None + is_volume = not book_id or book_id == content_id + group_key = str(book_id) if book_id else content_id + if group_key not in grouped or is_volume: + grouped[group_key] = r + return [grouped[k] for k in sorted(grouped)] + + +def _title_key(title: str, authors: tuple[str, ...]) -> str: + """Fallback join key, matching :func:`ingest.unify.normalize_key` (koreader.py's idiom).""" + from ingest.unify import normalize_key + + return normalize_key(title, authors) + + +def read_stats(conn: sqlite3.Connection) -> list[ReadingStat]: + """Read per-book stats from an open read-only Kobo connection.""" + if not table_exists(conn, "content"): + return [] + cols = columns(conn, "content") + stats: list[ReadingStat] = [] + for r in _top_level_rows(conn, cols): + title = str(r["Title"]) if "Title" in cols and r["Title"] else "" + authors = ( + _normalize_authors(str(r["Attribution"])) + if "Attribution" in cols and r["Attribution"] + else () + ) + total_pages = ( + int(r["___NumPages"]) if "___NumPages" in cols and r["___NumPages"] is not None else 0 + ) + pct = ( + int(r["___PercentRead"]) + if "___PercentRead" in cols and r["___PercentRead"] is not None + else 0 + ) + read_status = ( + int(r["ReadStatus"]) if "ReadStatus" in cols and r["ReadStatus"] is not None else 0 + ) + if read_status == READ_STATUS_FINISHED and total_pages > 0: + pages_read = total_pages + elif total_pages > 0: + pages_read = round(pct / 100 * total_pages) + else: + pages_read = 0 + read_time = ( + int(r["TimeSpentReading"]) + if "TimeSpentReading" in cols and r["TimeSpentReading"] is not None + else 0 + ) + last_read = ( + _parse_timestamp(str(r["DateLastRead"])) + if "DateLastRead" in cols and r["DateLastRead"] + else 0 + ) + stats.append( + ReadingStat( + key=_title_key(title, authors), + title=title, + authors=authors, + pages_read=pages_read, + total_pages=total_pages, + read_time_seconds=read_time, + last_read_ts=last_read, + sessions=0, # Kobo's `content` table has no per-session log. + highlights=0, # not tracked in `content`; annotations are a future source + ) + ) + return stats + + +def load_stats(kobo_db: Path, snapshot_dir: Path) -> list[ReadingStat]: + """Snapshot the Kobo DB and return all stats — the read-only entry point.""" + with open_snapshot(kobo_db, snapshot_dir) as conn: + return read_stats(conn) diff --git a/ingest/refresh.py b/ingest/refresh.py index e6eed2a..0cb6e9a 100644 --- a/ingest/refresh.py +++ b/ingest/refresh.py @@ -28,6 +28,7 @@ from ingest.calibre import load_library from ingest.config import KNOWN_STACKS_ENV, Config +from ingest.kobo import load_stats as load_kobo_stats from ingest.koreader import load_daily_activity, load_stats from ingest.kosync import FixtureKosync, ProgressSource from ingest.models import DailyActivity, DeviceProgress, ReadingStat, ReadingState @@ -174,7 +175,11 @@ class RefreshResult: def source_mtimes(config: Config) -> dict[str, int]: """Integer mtimes of the configured source files that currently exist.""" out: dict[str, int] = {} - for name, path in (("calibre", config.calibre_db), ("koreader", config.koreader_db)): + for name, path in ( + ("calibre", config.calibre_db), + ("koreader", config.koreader_db), + ("kobo", config.kobo_db), + ): if path is not None and path.is_file(): out[name] = int(path.stat().st_mtime) return out @@ -202,6 +207,7 @@ def _ingest_real( snap = config.snapshot_dir books = load_library(config.calibre_db, snap) if config.calibre_db else [] stats = load_stats(config.koreader_db, snap) if config.koreader_db else [] + stats = stats + (load_kobo_stats(config.kobo_db, snap) if config.kobo_db else []) activity = load_daily_activity(config.koreader_db, snap) if config.koreader_db else [] progress_result = _resolve_progress(_kosync(config), stats, store, now) states = unify(books, stats, FixtureKosync(progress_result.progress)) @@ -322,6 +328,8 @@ def doctor( if not config.demo: checks.extend(_check_source("Calibre", config.calibre_db, "books")) checks.extend(_check_source("KOReader", config.koreader_db, "book")) + if config.kobo_db is not None: + checks.extend(_check_source("Kobo", config.kobo_db, "content")) checks.append( Check( "kosync", diff --git a/tests/test_config.py b/tests/test_config.py index 1c205f8..3be6f17 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ def test_defaults_when_nothing_set(tmp_path: Path) -> None: cfg = load_config(env={}, config_path=tmp_path / "absent.toml") assert cfg.calibre_db is None assert cfg.koreader_db is None + assert cfg.kobo_db is None assert cfg.data_dir == DEFAULT_DATA_DIR assert cfg.demo is False assert cfg.kosync_configured is False @@ -22,6 +23,7 @@ def test_env_overrides(tmp_path: Path) -> None: env = { "STACKS_CALIBRE_DB": "/lib/metadata.db", "STACKS_KOREADER_DB": "/lib/statistics.sqlite", + "STACKS_KOBO_DB": "/lib/KoboReader.sqlite", "STACKS_DATA_DIR": str(tmp_path / "state"), "STACKS_KOSYNC_HOST": "https://sync.example", "STACKS_KOSYNC_USER": "reader", @@ -31,6 +33,7 @@ def test_env_overrides(tmp_path: Path) -> None: cfg = load_config(env=env, config_path=tmp_path / "absent.toml") assert cfg.calibre_db == Path("/lib/metadata.db") assert cfg.koreader_db == Path("/lib/statistics.sqlite") + assert cfg.kobo_db == Path("/lib/KoboReader.sqlite") assert cfg.data_dir == tmp_path / "state" assert cfg.demo is True assert cfg.kosync_configured is True @@ -39,6 +42,16 @@ def test_env_overrides(tmp_path: Path) -> None: assert cfg.snapshot_dir == tmp_path / "state" / "snapshots" +def test_kobo_only_counts_as_a_real_source(tmp_path: Path) -> None: + cfg = load_config( + env={"STACKS_KOBO_DB": "/lib/KoboReader.sqlite"}, config_path=tmp_path / "absent.toml" + ) + assert cfg.calibre_db is None + assert cfg.koreader_db is None + assert cfg.kobo_db == Path("/lib/KoboReader.sqlite") + assert cfg.has_real_sources is True + + def test_toml_file_is_read(tmp_path: Path) -> None: toml = tmp_path / "stacks.toml" toml.write_text( @@ -47,6 +60,8 @@ def test_toml_file_is_read(tmp_path: Path) -> None: path = "/books/metadata.db" [koreader] path = "/books/statistics.sqlite" + [kobo] + path = "/books/KoboReader.sqlite" [kosync] host = "https://sync.example" user = "me" @@ -58,6 +73,7 @@ def test_toml_file_is_read(tmp_path: Path) -> None: cfg = load_config(env={}, config_path=toml) assert cfg.calibre_db == Path("/books/metadata.db") assert cfg.koreader_db == Path("/books/statistics.sqlite") + assert cfg.kobo_db == Path("/books/KoboReader.sqlite") assert cfg.kosync_host == "https://sync.example" assert cfg.kosync_user == "me" assert cfg.data_dir == Path("/srv/stacks-data") diff --git a/tests/test_kobo.py b/tests/test_kobo.py new file mode 100644 index 0000000..4c3932c --- /dev/null +++ b/tests/test_kobo.py @@ -0,0 +1,300 @@ +"""Kobo native reader: per-book stats from `KoboReader.sqlite`'s `content` table.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from ingest.kobo import load_stats, read_stats +from ingest.snapshot import open_readonly + +_CREATE_CONTENT = """ +CREATE TABLE content ( + ContentID TEXT PRIMARY KEY, + ContentType INTEGER, + MimeType TEXT, + BookID TEXT, + Title TEXT, + Attribution TEXT, + ___PercentRead INTEGER, + ReadStatus INTEGER, + TimeSpentReading INTEGER, + ___NumPages INTEGER, + DateLastRead TEXT +); +""" + + +def _make_db(path: Path, rows: list[tuple]) -> None: + conn = sqlite3.connect(path) + conn.executescript(_CREATE_CONTENT) + conn.executemany( + """ + INSERT INTO content ( + ContentID, ContentType, MimeType, BookID, Title, Attribution, + ___PercentRead, ReadStatus, TimeSpentReading, ___NumPages, DateLastRead + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + conn.commit() + conn.close() + + +def test_reads_finished_reading_and_unread_books(tmp_path: Path) -> None: + db = tmp_path / "KoboReader.sqlite" + _make_db( + db, + [ + ( + "file:///finished.kepub.epub", + 6, + "application/x-kobo-epub+zip", + None, + "Kindred", + "Octavia E. Butler", + 100, + 2, + 12000, + 287, + "2026-05-01T10:00:00.000", + ), + ( + "file:///reading.kepub.epub", + 6, + "application/x-kobo-epub+zip", + None, + "Stone Butch Blues", + "Leslie Feinberg", + 42, + 1, + 3000, + 300, + "2026-05-15T20:30:00.000", + ), + ( + "file:///unread.kepub.epub", + 6, + "application/x-kobo-epub+zip", + None, + "Gender Outlaw", + "Kate Bornstein", + 0, + 0, + 0, + 200, + None, + ), + ], + ) + with open_readonly(db) as conn: + stats = read_stats(conn) + assert len(stats) == 3 + by_title = {s.title: s for s in stats} + + kindred = by_title["Kindred"] + assert kindred.authors == ("Octavia E. Butler",) + assert kindred.total_pages == 287 + assert kindred.is_finished + assert kindred.pages_read == 287 + assert kindred.read_time_seconds == 12000 + assert kindred.last_read_ts > 0 + assert kindred.sessions == 0 + assert kindred.key # non-empty title|author join key + + sbb = by_title["Stone Butch Blues"] + assert not sbb.is_finished + assert 0.0 < sbb.percent_complete < 1.0 + assert sbb.pages_read == round(42 / 100 * 300) + + unread = by_title["Gender Outlaw"] + assert unread.pages_read == 0 + assert unread.read_time_seconds == 0 + assert unread.last_read_ts == 0 + + +def test_deduplicates_chapter_rows_to_the_volume_row(tmp_path: Path) -> None: + """Kobo stores one `content` row per chapter; only the volume row should surface.""" + db = tmp_path / "KoboReader.sqlite" + _make_db( + db, + [ + ( + "file:///book.kepub.epub", + 6, + "application/x-kobo-epub+zip", + None, # volume row: BookID is NULL / self + "Beloved", + "Toni Morrison", + 55, + 1, + 5000, + 350, + "2026-06-01T08:00:00.000", + ), + ( + "file:///book.kepub.epub!chapter1", + 6, + "application/xhtml+xml", + "file:///book.kepub.epub", # chapter row: BookID points at the volume + "Beloved", + "Toni Morrison", + None, + None, + None, + None, + None, + ), + ( + "file:///book.kepub.epub!chapter2", + 6, + "application/xhtml+xml", + "file:///book.kepub.epub", + "Beloved", + "Toni Morrison", + None, + None, + None, + None, + None, + ), + ], + ) + with open_readonly(db) as conn: + stats = read_stats(conn) + assert len(stats) == 1 + assert stats[0].total_pages == 350 + assert stats[0].pages_read == round(55 / 100 * 350) + + +def test_kobo_without_optional_columns(tmp_path: Path) -> None: + """An older/variant Kobo `content` table lacking optional columns still reads.""" + db = tmp_path / "old.sqlite" + conn = sqlite3.connect(db) + conn.executescript( + """ + CREATE TABLE content ( + ContentID TEXT PRIMARY KEY, ContentType INTEGER, + Title TEXT, Attribution TEXT, ___PercentRead INTEGER + ); + INSERT INTO content (ContentID, ContentType, Title, Attribution, ___PercentRead) + VALUES ('file:///old.epub', 6, 'Old Format', 'Author', 50); + """ + ) + conn.commit() + conn.close() + with open_readonly(db) as ro: + stats = read_stats(ro) + assert len(stats) == 1 + s = stats[0] + assert s.title == "Old Format" + assert s.authors == ("Author",) + assert s.total_pages == 0 # ___NumPages absent -> default + assert s.pages_read == 0 # no total_pages to compute a fraction against + assert s.read_time_seconds == 0 # TimeSpentReading absent -> default + assert s.last_read_ts == 0 # DateLastRead absent -> default + assert s.sessions == 0 + assert s.highlights == 0 + + +def test_date_last_read_formats(tmp_path: Path) -> None: + """Zulu-suffixed and unparsable `DateLastRead` values are handled gracefully.""" + db = tmp_path / "KoboReader.sqlite" + _make_db( + db, + [ + ( + "file:///zulu.epub", + 6, + "application/x-kobo-epub+zip", + None, + "Zulu Time", + "Author One", + 10, + 1, + 60, + 100, + "2026-06-10T12:00:00Z", + ), + ( + "file:///garbage.epub", + 6, + "application/x-kobo-epub+zip", + None, + "Garbage Date", + "Author Two", + 10, + 1, + 60, + 100, + "not-a-date", + ), + ], + ) + with open_readonly(db) as conn: + stats = read_stats(conn) + by_title = {s.title: s for s in stats} + assert by_title["Zulu Time"].last_read_ts > 0 + assert by_title["Garbage Date"].last_read_ts == 0 + + +def test_content_table_without_content_id_column(tmp_path: Path) -> None: + """A `content` table missing `ContentID` skips dedup but still reads rows.""" + db = tmp_path / "no_id.sqlite" + conn = sqlite3.connect(db) + conn.executescript( + """ + CREATE TABLE content ( + ContentType INTEGER, Title TEXT, Attribution TEXT, ___PercentRead INTEGER + ); + INSERT INTO content (ContentType, Title, Attribution, ___PercentRead) + VALUES (6, 'No ID Book', 'Author', 25); + """ + ) + conn.commit() + conn.close() + with open_readonly(db) as ro: + stats = read_stats(ro) + assert len(stats) == 1 + assert stats[0].title == "No ID Book" + + +def test_empty_kobo_db(tmp_path: Path) -> None: + db = tmp_path / "empty.sqlite" + conn = sqlite3.connect(db) + conn.executescript("CREATE TABLE unrelated (x INTEGER);") + conn.commit() + conn.close() + with open_readonly(db) as ro: + assert read_stats(ro) == [] + + +def test_load_stats_snapshots_and_reads(tmp_path: Path) -> None: + db = tmp_path / "KoboReader.sqlite" + _make_db( + db, + [ + ( + "file:///a.epub", + 6, + "application/x-kobo-epub+zip", + None, + "A Book", + "Some Author", + 10, + 1, + 60, + 100, + "2026-06-10T00:00:00.000", + ) + ], + ) + stats = load_stats(db, tmp_path / "snapshots") + assert len(stats) == 1 + assert stats[0].title == "A Book" + + +def test_load_stats_missing_file_raises(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + load_stats(tmp_path / "nope.sqlite", tmp_path / "snapshots") diff --git a/tests/test_refresh_doctor.py b/tests/test_refresh_doctor.py index 256783d..4f205d8 100644 --- a/tests/test_refresh_doctor.py +++ b/tests/test_refresh_doctor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sqlite3 from pathlib import Path import pytest @@ -34,6 +35,45 @@ def _real_config(tmp_path: Path) -> Config: ) +def _make_kobo_db(path: Path) -> None: + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE content ( + ContentID TEXT PRIMARY KEY, ContentType INTEGER, MimeType TEXT, + BookID TEXT, Title TEXT, Attribution TEXT, ___PercentRead INTEGER, + ReadStatus INTEGER, TimeSpentReading INTEGER, ___NumPages INTEGER, + DateLastRead TEXT + ); + INSERT INTO content ( + ContentID, ContentType, MimeType, BookID, Title, Attribution, + ___PercentRead, ReadStatus, TimeSpentReading, ___NumPages, DateLastRead + ) VALUES ( + 'file:///kobo-only.epub', 6, 'application/x-kobo-epub+zip', NULL, + 'Kobo Only Book', 'Kobo Author', 30, 1, 900, 100, '2026-06-01T00:00:00.000' + ); + """ + ) + conn.commit() + conn.close() + + +def _real_config_with_kobo(tmp_path: Path) -> Config: + """A non-demo config with Calibre + KOReader + a Kobo native DB configured.""" + metadata_db, statistics_db = build_demo_dbs(tmp_path / "lib") + kobo_db = tmp_path / "lib" / "KoboReader.sqlite" + _make_kobo_db(kobo_db) + return load_config( + env={ + "STACKS_CALIBRE_DB": str(metadata_db), + "STACKS_KOREADER_DB": str(statistics_db), + "STACKS_KOBO_DB": str(kobo_db), + "STACKS_DATA_DIR": str(tmp_path / "data"), + }, + config_path=tmp_path / "absent.toml", + ) + + def test_demo_refresh_populates_store(tmp_path: Path) -> None: cfg = load_config( env={"STACKS_DEMO": "1", "STACKS_DATA_DIR": str(tmp_path / "data")}, @@ -73,6 +113,38 @@ def test_mtime_guard_skips_unchanged(tmp_path: Path) -> None: assert store.refreshed_at() == 300 +def test_real_refresh_merges_kobo_stats_through_unify(tmp_path: Path) -> None: + """Kobo stats flow through the same `unify` join, surfacing an unmatched book.""" + cfg = _real_config_with_kobo(tmp_path) + states, _ = ingest_states(cfg) + kobo_state = next(s for s in states if s.title == "Kobo Only Book") + assert kobo_state.book is None # not in the Calibre catalog + assert kobo_state.stat is not None + assert kobo_state.stat.read_time_seconds == 900 + # Existing Calibre + KOReader books are unaffected. + assert any(s.title == "Kindred" for s in states) + + +def test_source_mtimes_includes_kobo(tmp_path: Path) -> None: + cfg = _real_config_with_kobo(tmp_path) + mtimes = source_mtimes(cfg) + assert "kobo" in mtimes + + +def test_doctor_reports_kobo_when_configured(tmp_path: Path) -> None: + cfg = _real_config_with_kobo(tmp_path) + checks = doctor(cfg) + by_name = {c.name: c for c in checks} + assert by_name["Kobo file"].ok + assert by_name["Kobo read-only access"].ok + + +def test_doctor_omits_kobo_when_unconfigured(tmp_path: Path) -> None: + cfg = _real_config(tmp_path) + checks = doctor(cfg) + assert not any(c.name.startswith("Kobo") for c in checks) + + def test_source_mtimes_only_existing(tmp_path: Path) -> None: cfg = load_config( env={"STACKS_CALIBRE_DB": str(tmp_path / "missing.db")},