fix(koplugin): fold duplicate stats book rows so synced time shows in KOReader (#4895)

The stats pull keyed the statistics book table by md5 alone, while
KOReader's native statistics plugin keys rows by exact (title, authors,
md5). When the two parsers extract slightly different metadata for the
same file, the first native open creates a second, zeroed book row that
the KOReader UI reads, and the reading time synced from Readest stays
stranded on the sync-created row.

applyRemote now inserts a book row only for an md5 the DB has never
seen, attaches pulled events to the row the native plugin reads (native
rows always set pages and last_open; sync-created rows leave pages
NULL), and folds never-adopted duplicate rows into the surviving row on
every pull, so existing databases heal and the stranded time reappears.
Adopted rows and the live session's cached book id are never deleted,
and the totals recompute no longer regresses last_open below a real
native open timestamp.

Fixes #4861

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-03 01:31:06 +09:00
committed by GitHub
parent 849f151166
commit bd415a8501
3 changed files with 238 additions and 8 deletions
@@ -129,6 +129,159 @@ describe("readest_syncstats", function()
assert.are.equal(0, tonumber(pcount))
end)
-- #4861: KOReader's statistics plugin keys book rows by (title, authors,
-- md5) — UNIQUE INDEX book_title_authors_md5 — while the sync pull keys
-- by md5 alone. When KOReader's extracted metadata drifts from Readest's,
-- the first native open adds a second, zeroed row (always with pages and
-- last_open set) and the synced reading time is stranded on the
-- sync-created row (pages NULL) that nothing reads anymore.
it("folds a stranded sync-created duplicate into the native book row (#4861)", function()
local conn = SQ3.open(statsDbPath())
-- a pull that ran before the book was first opened natively created
-- this row from Readest's metadata, holding the Readest reading time
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('Book: subtitle', 'Auth', 'md5-dup');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 1, 100, 60, 10);")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 2, 200, 60, 10);")
-- the first native open then missed on (title, authors, md5) and
-- inserted its own zeroed row
conn:exec([[INSERT INTO book (title, authors, md5, pages, last_open, total_read_time, total_read_pages)
VALUES ('Book - subtitle', 'Auth', 'md5-dup', 10, 1000, 0, 0);]])
conn:close()
SyncStats:applyRemote({}, {}) -- a pull with nothing new still heals
local conn2 = SQ3.open(statsDbPath())
local rows = conn2:rowexec("SELECT COUNT(*) FROM book WHERE md5 = 'md5-dup';")
local title = conn2:rowexec("SELECT title FROM book WHERE md5 = 'md5-dup';")
local total = conn2:rowexec("SELECT total_read_time FROM book WHERE md5 = 'md5-dup';")
local last_open = conn2:rowexec("SELECT last_open FROM book WHERE md5 = 'md5-dup';")
local orphans = conn2:rowexec(
"SELECT COUNT(*) FROM page_stat_data WHERE id_book NOT IN (SELECT id FROM book);")
conn2:close()
assert.are.equal(1, tonumber(rows))
assert.are.equal("Book - subtitle", title) -- the native row survives
assert.are.equal(120, tonumber(total))
assert.are.equal(1000, tonumber(last_open)) -- native open time not regressed
assert.are.equal(0, tonumber(orphans))
end)
it("keeps the longer duration when duplicate rows hold the same page event", function()
local conn = SQ3.open(statsDbPath())
conn:exec([[INSERT INTO book (title, authors, md5, pages, last_open, total_read_time, total_read_pages)
VALUES ('Native', 'A', 'md5-ov', 10, 50, 30, 1);]])
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 1, 100, 30, 10);")
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('Synced', 'A', 'md5-ov');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (2, 1, 100, 90, 10);")
conn:close()
SyncStats:applyRemote({}, {})
local conn2 = SQ3.open(statsDbPath())
local rows = conn2:rowexec("SELECT COUNT(*) FROM book WHERE md5 = 'md5-ov';")
local total = conn2:rowexec("SELECT total_read_time FROM book WHERE md5 = 'md5-ov';")
conn2:close()
assert.are.equal(1, tonumber(rows))
assert.are.equal(90, tonumber(total))
end)
it("does not create a duplicate row when the md5 exists under drifted metadata", function()
local conn = SQ3.open(statsDbPath())
conn:exec([[INSERT INTO book (title, authors, md5, pages, last_open, total_read_time, total_read_pages)
VALUES ('KO Title', 'KO Author', 'md5-x', 42, 1000, 0, 0);]])
conn:close()
SyncStats:applyRemote(
{ { book_hash = "md5-x", title = "Readest Title", authors = "Readest Author" } },
{ { book_hash = "md5-x", page = 3, start_time = 500, duration = 30, total_pages = 42 } })
local conn2 = SQ3.open(statsDbPath())
local rows = conn2:rowexec("SELECT COUNT(*) FROM book WHERE md5 = 'md5-x';")
local title = conn2:rowexec("SELECT title FROM book WHERE md5 = 'md5-x';")
local total = conn2:rowexec("SELECT total_read_time FROM book WHERE md5 = 'md5-x';")
conn2:close()
assert.are.equal(1, tonumber(rows))
assert.are.equal("KO Title", title)
assert.are.equal(30, tonumber(total))
end)
-- Rows KOReader itself created (pages set) are never deleted: an open
-- reader session may hold a cached id_book pointing at either of them.
it("leaves duplicate rows that KOReader itself adopted untouched", function()
local conn = SQ3.open(statsDbPath())
conn:exec([[INSERT INTO book (title, authors, md5, pages, last_open, total_read_time, total_read_pages)
VALUES ('Old Title', 'A', 'md5-nat', 10, 100, 5, 1);]])
conn:exec([[INSERT INTO book (title, authors, md5, pages, last_open, total_read_time, total_read_pages)
VALUES ('New Title', 'A', 'md5-nat', 10, 200, 7, 1);]])
conn:close()
SyncStats:applyRemote({}, {})
local conn2 = SQ3.open(statsDbPath())
local rows = conn2:rowexec("SELECT COUNT(*) FROM book WHERE md5 = 'md5-nat';")
conn2:close()
assert.are.equal(2, tonumber(rows))
end)
-- While a book is open, KOReader's statistics session holds a cached book
-- id (ui.statistics.id_curr_book). If that session adopted a sync-created
-- row (pages stays NULL until the first close writes it), folding must not
-- delete the row out from under the session, or the rest of its stats
-- would be written to a dangling id.
it("never folds the row cached by the live statistics session", function()
local conn = SQ3.open(statsDbPath())
-- legacy sync-created row the current session adopted (pages NULL)
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('T', 'A', 'md5-live');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 1, 100, 10, 10);")
-- older native row left by a previous KOReader version's metadata
conn:exec([[INSERT INTO book (title, authors, md5, pages, last_open, total_read_time, total_read_pages)
VALUES ('T (old)', 'A', 'md5-live', 10, 50, 0, 0);]])
conn:close()
SyncStats:applyRemote({}, {}, 1) -- id 1 is the live session's row
local conn2 = SQ3.open(statsDbPath())
local live = conn2:rowexec("SELECT COUNT(*) FROM book WHERE id = 1;")
conn2:close()
assert.are.equal(1, tonumber(live))
end)
it("derives the live statistics book id from ui during pull", function()
local conn = SQ3.open(statsDbPath())
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('T', 'A', 'md5-ui');")
conn:exec([[INSERT INTO book (title, authors, md5, pages, last_open, total_read_time, total_read_pages)
VALUES ('T2', 'A', 'md5-ui', 10, 50, 0, 0);]])
conn:close()
local client = { pullChanges = function(_, _params, cb)
cb(true, { statBooks = {}, statPages = {} }, 200)
end }
local ui = { statistics = { id_curr_book = 1 } }
SyncStats:pull({ stats_pull_cursor = 0 }, client, false, nil, ui)
local conn2 = SQ3.open(statsDbPath())
local live = conn2:rowexec("SELECT COUNT(*) FROM book WHERE id = 1;")
conn2:close()
assert.are.equal(1, tonumber(live))
end)
it("merges duplicate sync-created rows when KOReader never opened the book", function()
local conn = SQ3.open(statsDbPath())
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('Title v1', 'A', 'md5-s');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 1, 100, 10, 10);")
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('Title v2', 'A', 'md5-s');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (2, 2, 200, 20, 10);")
conn:close()
SyncStats:applyRemote({}, {})
local conn2 = SQ3.open(statsDbPath())
local rows = conn2:rowexec("SELECT COUNT(*) FROM book WHERE md5 = 'md5-s';")
local total = conn2:rowexec("SELECT total_read_time FROM book WHERE md5 = 'md5-s';")
conn2:close()
assert.are.equal(1, tonumber(rows))
assert.are.equal(30, tonumber(total))
end)
it("recomputes book totals after applying remote events", function()
SyncStats:applyRemote(
{ { book_hash = "md5-tot", title = "Tot", authors = "A" } },