fix: remember ratings in 5-star space — odd server ratings no longer drift up

Change-detection compared a derived 10-scale value against a remembered
raw 10-scale value, but the summary can only hold whole stars: a pulled
server 9 (4.5 stars) applied as 5 stars, remembered 9, and the next
close-push derived 10 ≠ 9 and silently rounded the server rating up.
Every odd rating (9/7/5/3/1) drifted one half-star on the first close
after a pull.

The remembered state is now the star count actually written (5-space);
the POST fires only when the star count itself changed, converting at
the boundary. koreader's display is an array-index lookup (a fractional
rating would render nil), so whole stars are the only safe memory.
getMetaSyncState normalizes any 10-space value an older build wrote.

Verified: s → stars → 2s → stars is stable for all s in 1..10.
This commit is contained in:
John O'Keefe committed 2026-10-15 11:38:50 -04:00
1 parent 001628f2a2
commit b0ff330b0f
1 file changed
+25 -13
+25 -13
View File
@@ -1980,6 +1980,11 @@ function Bookhoard:getMetaSyncState(book_key)
if type(state) ~= "table" then if type(state) ~= "table" then
state = {} state = {}
self.settings.meta_sync_state[book_key] = state self.settings.meta_sync_state[book_key] = state
elseif type(state.rating) == "number" and state.rating > 5 then
-- Settings written by the first bridge build remembered ratings
-- in 10-space; normalize once. 10-space memory cannot survive
-- the round-trip (odd values would re-round at every push).
state.rating = rating10to5(state.rating)
end end
return state return state
end end
@@ -2007,20 +2012,24 @@ function Bookhoard:pullBookMeta(interactive)
if self.settings.sync_ratings then if self.settings.sync_ratings then
local ok, result = api:getRating(ref) local ok, result = api:getRating(ref)
if ok and result and not result.error then if ok and result and not result.error then
local server10 = result.rating -- Compare in 5-STAR SPACE. The summary cannot represent odd
local server5 = rating10to5(server10) -- server values (9 = 4.5 stars), so remembering the raw
-- 10-scale would make every pulled odd rating look "changed"
-- at push time (9 remembered → 5★ local → push derives 10)
-- and silently round it up. Remember the star count we
-- actually wrote; conversion to 10 happens only at the POST.
local server5 = rating10to5(result.rating)
local local5 = summary.rating or 0 local local5 = summary.rating or 0
local local10 = rating5to10(local5) or 0 local remembered5 = remembered.rating or 0
local remembered10 = remembered.rating or 0 if server5 ~= local5 and remembered5 == local5 then
if server10 ~= nil and server10 ~= local10 and remembered10 == local10 then
-- Server moved, koreader didn't → apply. -- Server moved, koreader didn't → apply.
summary.rating = server5 summary.rating = server5
self:writeSummary(summary) self:writeSummary(summary)
remembered.rating = server10 remembered.rating = server5
table.insert(applied, T(_("rating %1/5"), server5)) table.insert(applied, T(_("rating %1/5"), server5))
elseif server10 ~= nil and server10 == local10 and remembered10 ~= local10 then elseif server5 == local5 and remembered5 ~= local5 then
-- Values agree but our memory is stale; just re-sync memory. -- Values agree but our memory is stale; just re-sync memory.
remembered.rating = server10 remembered.rating = server5
end end
end end
end end
@@ -2068,12 +2077,15 @@ function Bookhoard:pushBookMeta(interactive)
local api = self:getAPI() local api = self:getAPI()
if self.settings.sync_ratings then if self.settings.sync_ratings then
local local10 = rating5to10(summary.rating) or 0 -- 5-star space comparison (see the pull): the POST only fires
local remembered10 = remembered.rating or 0 -- when the star count itself changed; conversion happens at the
if local10 ~= remembered10 then -- boundary (nil/0 → server clears).
local ok, result = api:setRating(ref, local10) local local5 = summary.rating or 0
local remembered5 = remembered.rating or 0
if local5 ~= remembered5 then
local ok, result = api:setRating(ref, rating5to10(local5) or 0)
if ok and result and not result.error then if ok and result and not result.error then
remembered.rating = local10 remembered.rating = local5
table.insert(pushed, _("rating")) table.insert(pushed, _("rating"))
else else
errors = errors + 1 errors = errors + 1