Skip to content

fix: reading sessions silently never sync for books linked outside the plugin - #201

Open
luizcorreia wants to merge 6 commits into
grimmory-tools:mainfrom
luizcorreia:fix/reading-session-sync
Open

fix: reading sessions silently never sync for books linked outside the plugin#201
luizcorreia wants to merge 6 commits into
grimmory-tools:mainfrom
luizcorreia:fix/reading-session-sync

Conversation

@luizcorreia

@luizcorreia luizcorreia commented Jul 23, 2026

Copy link
Copy Markdown

Summary

Reading progress (page position) syncs fine, but reading session
stats
(time spent, pages read per session) can silently never reach
Grimmory for books that weren't downloaded through this plugin - e.g.
books added via OPDS, copied in manually, or carried over from a
previous KOReader setup.

Root cause, in order:

  1. A book only ever gets a grimmory_id by being downloaded through
    this plugin, or by the user manually running "Sync with Grimmory"
    on that specific file. Nothing in the full/periodic sync path ever
    tries to establish that link for books that already exist locally.
  2. getBooksPendingSync requires grimmory_id IS NOT NULL, so an
    unlinked book's sessions are never even attempted during a full
    sync - not an error, just silently skipped, forever.
  3. If a session does get attempted for an unlinked book (e.g. via
    manual per-file sync), pushBookSessions hit a session.grimmory_id == nil branch that logged an error and break-ed out of the loop
    entirely, discarding every other pending session for that book in
    the same run.
  4. Separately, shelf sync only recognized a book as "already have it"
    by an exact expected filename inside the configured download
    directory. A book present locally under any other name was
    invisible to that check, so shelf sync would download a duplicate
    copy - leaving the original (with all the real reading history)
    orphaned with no grimmory_id, and the fresh duplicate with none of
    that history.
  5. GrimmoryAPI:recordSession also hardcoded bookType = "EPUB" for
    every session regardless of the book's actual format.

Changes (4 commits)

  • fix: derive reading-session book type from the file extension
    • Derives bookType from the file extension instead of hardcoding
      "EPUB", falling back to the old default when it can't be
      determined.
  • fix: don't abort every pending session when a book isn't linked to Grimmory
    • grimmory_id is constant per book for a given pushBookSessions
      call, so a missing link is checked once up front and reported as a
      single session-unlinked event instead of failing (and breaking
      out on) every pending session individually. Real API failures
      still break the loop so a run can retry.
  • feat: automatically link books with reading activity during full sync
    • Adds getUnlinkedBooksWithEvents() + associateUnlinkedBooks(),
      which tries to match locally-known books with reading activity but
      no grimmory_id against the remote catalog (reusing the existing
      ISBN/ASIN doc_metadata:isBook matching) before a full sync
      decides what's pending. The remote catalog is fetched once per
      sync and reused across every unlinked book, not once per book.
      Surfaces book-linked / session-unlinked in the sync summary
      toast so this is no longer silent.
  • fix: avoid re-downloading books that already exist locally under another name
    • Adds getUnlinkedBooks() + findLocalBookMatch(): before
      downloading, checks already-known local files with no
      grimmory_id against the remote catalog entry via the same
      ISBN/ASIN matching, and reuses the existing file instead of
      downloading a duplicate when one matches.

This was found and reproduced against a real self-hosted Grimmory
instance + KOReader device: a book read entirely outside the plugin's
own download flow had 0 rows ever recorded via
book_sync_status/reading-sessions, and a shelf sync produced an
exact duplicate of a book already present locally under a different
filename.

Test plan

  • busted spec/ - 48/48 passing (39 pre-existing + 9 new, plus
    extended coverage on the 3 pre-existing behaviors touched)
  • luacheck . --no-self - 0 warnings across all files
  • Each commit verified independently (tests + lint green at every
    step, not just at the tip)
  • Manual on-device verification on a follow-up sync (only unit-level
    coverage so far - synchronize.lua had no existing spec file,
    this PR adds one)

Summary by CodeRabbit

  • New Features
    • Reading-session syncing now uses the book’s actual file type (instead of assuming EPUB).
    • Added automatic linking for locally unlinked books with reading activity by matching them to the catalog.
    • Synchronization now reuses existing matching local files when preparing book downloads.
  • Bug Fixes
    • Improved handling of unlinked sessions: reports unlinked sessions once per book and avoids per-session recording failures.
    • Completion messaging now includes counts for unlinked sessions and newly linked books.
  • Tests
    • Added comprehensive synchronization test coverage for these flows.

GrimmoryAPI:recordSession hardcoded bookType to "EPUB" for every
reading session pushed to Grimmory, regardless of the book's actual
format (PDF, CBZ, MOBI, ...). Sessions for non-EPUB books were being
recorded on the server with the wrong format.

Derive it instead from the book's file extension (falling back to the
previous "EPUB" default when it can't be determined), and thread it
through recordSession as an explicit parameter instead of a constant.
…immory

pushBookSessions would break out of the whole loop the first time it
hit a session whose book had no grimmory_id, logging it as a
"session-error". Since grimmory_id comes from the book itself, it's
the same for every pending session of that book - so this wasn't a
transient error worth retrying, it just silently stalled session sync
for that book on every future run (as long as it kept having reading
activity to push), with no path to recovery.

Check once up front instead: if the book isn't linked yet, report a
single "session-unlinked" event and return, rather than looping through
every pending session only to fail (and log) each one individually.
Real recordSession failures still break the loop so a run can retry.
Books only ever got a grimmory_id in two situations: being downloaded
by this plugin, or the user manually running "Sync with Grimmory" on
that specific file from the File Manager. A book already on disk from
somewhere else (an OPDS download, a manual copy, a previous KOReader
setup, ...) never got linked automatically, which meant a full/periodic
sync silently never pushed its reading sessions or progress at all:
getBooksPendingSync requires grimmory_id IS NOT NULL, and nothing in
the full-sync path ever tried to establish that link in the first
place.

Add GrimmoryLocalRepository:getUnlinkedBooksWithEvents() to find local
books that have reading activity but no Grimmory link, and
GrimmorySynchronize:associateUnlinkedBooks() to try to match them
against the remote catalog (reusing the existing ISBN/ASIN-based
associateBook matching) before a full sync decides what's pending.
associateBook now accepts an optional pre-fetched catalog so the
remote book list is only fetched once per sync, no matter how many
local books need matching, instead of once per book.

Surface both new outcomes ("book-linked", "session-unlinked") in the
sync summary toast so it's no longer silent when a book can't have its
reading activity synced.
…her name

Shelf sync only recognized a book as "already downloaded" by checking
a previously-tracked grimmory_id, or by guessing a filename inside the
configured download directory from the catalog's primaryFile name. A
book already present on disk from anywhere else - OPDS, a manual copy,
a prior KOReader setup - was invisible to that check, so shelf sync
would download a second copy under its own generated filename. The
original file (often the one with all the actual reading history) was
left as an orphan with no grimmory_id, and the fresh duplicate had none
of that history.

Add GrimmoryLocalRepository:getUnlinkedBooks() and
GrimmorySynchronize:findLocalBookMatch(), which checks already-known
local files with no grimmory_id against the remote catalog entry via
the same ISBN/ASIN-based doc_metadata:isBook() matching used elsewhere,
before falling back to constructing a fresh download path. When a
match is found, getBookDownloadPath returns the existing file instead,
and pullBook's existing upsertBook call links it in place - no
download, no duplicate.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The synchronization flow derives session book types, identifies unlinked activity, associates local books with remote catalog entries, reuses matching local files, and reports linking outcomes. Repository queries and synchronization specifications cover these behaviors.

Changes

Unlinked synchronization

Layer / File(s) Summary
Book-type-aware session recording
grimmory.koplugin/grimmory/grimmory_api.lua, grimmory.koplugin/grimmory/synchronize.lua, spec/grimmory/synchronize_spec.lua
Session recording accepts the derived file type, while unlinked sessions are reported and skipped.
Unlinked book association
grimmory.koplugin/grimmory/repository.lua, grimmory.koplugin/grimmory/synchronize.lua, spec/grimmory/synchronize_spec.lua
Repository queries expose unlinked books, and synchronization links matching books using one remote catalog fetch.
Local match reuse
grimmory.koplugin/grimmory/synchronize.lua, spec/grimmory/synchronize_spec.lua
Download resolution reuses existing local files that match remote book metadata.
Synchronization outcome reporting
grimmory.koplugin/main.lua
Verbose sync results include unlinked-session and linked-book counts.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Sync
  participant Repository
  participant API
  participant Metadata
  Sync->>Repository: Fetch unlinked books with events
  Sync->>API: Fetch remote catalog once
  Sync->>Metadata: Match local documents
  Sync->>Repository: Associate matching books
  Sync-->>Sync: Report book-linked
Loading

Possibly related PRs

Suggested labels: backend, feature

Suggested reviewers: imnotjames

Poem

I’m a rabbit with books in a row,
Linking lost tales wherever they go.
EPUB or MOBI, each type gets its say,
Local paths hop back into play.
“Book linked!” I cheer—then nibble hay.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title uses conventional commit format and clearly describes the session-sync bug fixed by the changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
spec/grimmory/synchronize_spec.lua (1)

194-312: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage for the new matching/reuse logic, but no test for pushAllPendingBookMetadata's integration with associateUnlinkedBooks.

Consider adding a test asserting associateUnlinkedBooks is invoked (and that a failure inside it doesn't prevent book_ids processing), which would also guard against the missing-pcall concern raised in grimmory.koplugin/grimmory/synchronize.lua (Lines 258-264).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/grimmory/synchronize_spec.lua` around lines 194 - 312, The existing
specs cover associateUnlinkedBooks directly but not its integration with
pushAllPendingBookMetadata. Add a test around pushAllPendingBookMetadata that
spies on associateUnlinkedBooks, verifies it is invoked, and simulates its
failure to confirm processing of book_ids still occurs without propagating the
error. Use the existing callback and repository/API spies to assert the
remaining metadata flow continues.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@grimmory.koplugin/grimmory/synchronize.lua`:
- Around line 511-521: The reused local file returned by findLocalBookMatch must
be linked to book.id even when downloadBook is skipped because the file already
exists. Update the existing-book branch in getBookDownloadPath and its caller so
the reused path performs upsertBook with the resolved download path and book.id,
while preserving the current download-success handling without duplicating
links.
- Around line 258-264: Wrap the associateUnlinkedBooks call in
GrimmorySynchronize:pushAllPendingBookMetadata with pcall, matching the
error-isolation pattern used by pushBookMetadata. Ensure any exception from
associateUnlinkedBooks is handled without aborting the subsequent book_ids
computation and metadata/session synchronization for already-linked books.

---

Nitpick comments:
In `@spec/grimmory/synchronize_spec.lua`:
- Around line 194-312: The existing specs cover associateUnlinkedBooks directly
but not its integration with pushAllPendingBookMetadata. Add a test around
pushAllPendingBookMetadata that spies on associateUnlinkedBooks, verifies it is
invoked, and simulates its failure to confirm processing of book_ids still
occurs without propagating the error. Use the existing callback and
repository/API spies to assert the remaining metadata flow continues.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e44f37de-0e4d-4715-805f-7b9219ce1268

📥 Commits

Reviewing files that changed from the base of the PR and between 51c1cd1 and 2d63e50.

📒 Files selected for processing (5)
  • grimmory.koplugin/grimmory/grimmory_api.lua
  • grimmory.koplugin/grimmory/repository.lua
  • grimmory.koplugin/grimmory/synchronize.lua
  • grimmory.koplugin/main.lua
  • spec/grimmory/synchronize_spec.lua
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • grimmory-tools/grimmory-docs (manual)
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-06-26T20:12:43.855Z
Learnt from: imnotjames
Repo: grimmory-tools/grimmory.koplugin PR: 161
File: grimmory.koplugin/grimmory/doc_metadata.lua:0-0
Timestamp: 2026-06-26T20:12:43.855Z
Learning: In the `grimmory.koplugin` sync architecture, the book-specific sync flow runs on a different thread from the UI code. When reviewing “duplicate UI reload” or “duplicate refresh” concerns, don’t assume calls in `grimmory.koplugin/main.lua` (e.g., `refreshUI()`) share the same execution path as progress updates like `DocMetadata:setProgress()` in `grimmory.koplugin/grimmory/doc_metadata.lua`. Confirm whether the code paths can run concurrently/independently before flagging them as duplicates.

Applied to files:

  • grimmory.koplugin/grimmory/repository.lua
  • grimmory.koplugin/main.lua
  • grimmory.koplugin/grimmory/grimmory_api.lua
  • grimmory.koplugin/grimmory/synchronize.lua
🔇 Additional comments (7)
grimmory.koplugin/grimmory/repository.lua (1)

813-858: LGTM!

Also applies to: 861-900

grimmory.koplugin/main.lua (1)

479-480: LGTM!

Also applies to: 553-556, 642-652

grimmory.koplugin/grimmory/grimmory_api.lua (1)

549-593: LGTM!

grimmory.koplugin/grimmory/synchronize.lua (3)

9-21: LGTM!


102-115: LGTM!

Also applies to: 136-151


640-667: 🚀 Performance & Scalability

No change needed. isBook caches document props by path, so repeated comparisons for the same local file do not re-parse the file on every remote book.

spec/grimmory/synchronize_spec.lua (1)

108-192: LGTM!

Comment thread grimmory.koplugin/grimmory/synchronize.lua
Comment thread grimmory.koplugin/grimmory/synchronize.lua
function GrimmorySynchronize:associateBook(book_path)
for book in self.api:getBooks() do
function GrimmorySynchronize:associateBook(book_path, remote_books)
local books = remote_books

@imnotjames imnotjames Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will run out of memory on some devices.

@imnotjames

Copy link
Copy Markdown
Collaborator

This looks AI generated. Is it?

pushAllPendingBookMetadata called associateUnlinkedBooks directly,
unlike the per-book pushBookMetadata call a few lines below which is
wrapped in pcall. If associateUnlinkedBooks raised - e.g. GrimmoryAPI's
getBooks() iterator calls error() on a mid-pagination network failure -
the exception would abort the whole function before book_ids was even
computed, silently skipping progress/session sync for every other
already-linked book in that run. That's exactly the kind of one-book
failure taking down unrelated books that this branch is otherwise
trying to fix.

(Flagged by CodeRabbit on PR grimmory-tools#201.)
…f loading it all into memory

associateUnlinkedBooks fetched the entire remote catalog into a Lua
table up front and reused it across every locally unlinked book, to
avoid re-fetching per book. On a large library this means holding
every Book (with its nested metadata/shelves tables) in memory at
once, which is a real concern on the memory-constrained e-readers this
plugin runs on.

Invert the loop instead: walk the catalog one page at a time via the
existing getBooks() iterator, and check each page against whichever
local books are still unmatched. Matched books are removed from the
remaining set as they're linked, so memory use stays bounded by however
many local books are still unlinked (typically small) rather than the
size of the whole remote catalog, and the scan can stop as soon as
everything's matched instead of reading the rest of the catalog.

associateBook (used separately by the single-file "Sync with Grimmory"
action) no longer needs a pre-fetched catalog parameter now that
associateUnlinkedBooks does its own streaming match, so it reverts to
its original single-book form.

(Flagged by a maintainer on PR grimmory-tools#201.)
@coderabbitai
coderabbitai Bot requested a review from imnotjames July 23, 2026 17:06
@luizcorreia

Copy link
Copy Markdown
Author

This looks AI generated. Is it?

Yeah, AI (Claude Code) helped write this, but it didn't just generate a diff from a description - it was directed through the actual investigation.

The bug was diagnosed against a real self-hosted Grimmory instance and my own KOReader grimmory.sqlite3/statistics.sqlite3 off a Kindle Paperwhite: confirmed book_sync_status had zero rows ever, found the exact book that had reading activity but no grimmory_id, and reproduced the shelf-sync duplicate-download bug with a real book that got downloaded twice under two different filenames/hashes. The fix was built in reviewed, tested increments (busted + luacheck green at every commit, not just at the tip), and I flashed the changed files straight onto that same Kindle afterward to confirm it actually behaves.

So: AI-assisted, not AI-generated. Happy to walk through any part of it in more detail if that helps the review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
grimmory.koplugin/grimmory/synchronize.lua (1)

517-550: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reused local-match still isn't linked to book.id — same gap flagged in the previous review.

findLocalBookMatch returns an existing local file path but never persists the association; getBookDownloadPath then returns that path immediately (line 546-549) without calling upsertBook. Since associateUnlinkedBooks only links unlinked books that already have reading-session events, a book matched here but not yet read stays permanently unlinked in the repository even though the correct file was identified — defeating the "detect existing local books... to avoid duplicate downloads" goal for any book association bookkeeping that depends on the link (progress/session sync for that file going forward). The prior review already surfaced this exact issue with verified evidence; the code is unchanged in that respect.

  • grimmory.koplugin/grimmory/synchronize.lua#L517-L550: call self.repository:upsertBook(local_match, book.id) (logging on failure, mirroring the pattern used elsewhere) before returning local_match from getBookDownloadPath.
  • spec/grimmory/synchronize_spec.lua#L317-L333: add an assertion that fake_repository.upsertBook was called with the matched path and remote_book.id, so this regression is caught going forward.
🐛 Proposed fix
     local local_match = self:findLocalBookMatch(book)
     if local_match ~= nil then
+        local link_ok = self.repository:upsertBook(local_match, book.id)
+        if not link_ok then
+            logger:err("Failed to link matched local book:", local_match, "-", book.id)
+        end
         return local_match
     end

Worth confirming the caller a few lines below (downloadBook, not shown in this diff) doesn't already handle this separately:

#!/bin/bash
rg -n -B5 -A40 'getBookDownloadPath\s*\(' grimmory.koplugin/grimmory/synchronize.lua
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@grimmory.koplugin/grimmory/synchronize.lua` around lines 517 - 550, Update
getBookDownloadPath to call self.repository:upsertBook(local_match, book.id)
before returning a reused local match, logging failure consistently with the
existing upsertBook handling. In spec/grimmory/synchronize_spec.lua lines
317-333, assert fake_repository.upsertBook receives the matched path and
remote_book.id.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@grimmory.koplugin/grimmory/synchronize.lua`:
- Around line 517-550: Update getBookDownloadPath to call
self.repository:upsertBook(local_match, book.id) before returning a reused local
match, logging failure consistently with the existing upsertBook handling. In
spec/grimmory/synchronize_spec.lua lines 317-333, assert
fake_repository.upsertBook receives the matched path and remote_book.id.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 036ff076-3437-4e65-970b-87bd71674580

📥 Commits

Reviewing files that changed from the base of the PR and between 2d63e50 and 983f2ce.

📒 Files selected for processing (2)
  • grimmory.koplugin/grimmory/synchronize.lua
  • spec/grimmory/synchronize_spec.lua
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • grimmory-tools/grimmory-docs (manual)
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-06-26T20:12:43.855Z
Learnt from: imnotjames
Repo: grimmory-tools/grimmory.koplugin PR: 161
File: grimmory.koplugin/grimmory/doc_metadata.lua:0-0
Timestamp: 2026-06-26T20:12:43.855Z
Learning: In the `grimmory.koplugin` sync architecture, the book-specific sync flow runs on a different thread from the UI code. When reviewing “duplicate UI reload” or “duplicate refresh” concerns, don’t assume calls in `grimmory.koplugin/main.lua` (e.g., `refreshUI()`) share the same execution path as progress updates like `DocMetadata:setProgress()` in `grimmory.koplugin/grimmory/doc_metadata.lua`. Confirm whether the code paths can run concurrently/independently before flagging them as duplicates.

Applied to files:

  • grimmory.koplugin/grimmory/synchronize.lua
🔇 Additional comments (2)
grimmory.koplugin/grimmory/synchronize.lua (1)

11-21: LGTM!

Also applies to: 88-183, 258-289, 645-660, 663-712

spec/grimmory/synchronize_spec.lua (1)

109-293: LGTM!

Also applies to: 295-316, 335-341

@luizcorreia

Copy link
Copy Markdown
Author

Any feedback on this PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants