feat: server-side latest-chapter polling #2
Reference in New Issue
Block a user
Delete Branch "explore/tls-fingerprint-fetch"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds a background goroutine to the backend that re-checks each bookmarked series' newest published chapter on its own schedule, so
latest_chapterstays fresh even when the manga sites are never opened in a browser.This is a second, parallel signal, not a replacement: the userscript keeps its own
maybeCaptureLatestOnSeriesPage/backgroundRefreshLatestlogic, unchanged.userscript/manga-bookmark.user.jsis byte-identical tomain.How it works
One ticker goroutine in the same binary. Each wake it asks SQLite for bookmarks whose
latest_checked_athas aged past a per-bookmark cooldown, fetches those series pages through a Chrome-fingerprinted HTTP client, extracts the max chapter number with a per-site regex, and writes it back throughStore.Get+Store.Upsert. Every failure path logs and moves on.Two independent clocks:
WHEREclause inStore.DueForLatestCheck, not by a timer.Shortening the interval therefore cannot shorten anyone's cooldown; it only makes the poller wake and find nothing due more often.
The row is stamped before the fetch, so an error, a timeout, or a shutdown mid-request still consumes the cooldown — a renamed or challenged series waits out a full cooldown instead of being retried every tick.
Design decisions worth reviewing
latest_checked_atis deliberately absent from theBookmarkstruct and frombookmarkColumns.PUT /bookmarks/{key}decodes a wholeBookmarkandUpsertwrites every column it knows about, so a userscript PUT — which has no idea this field exists — would write a zero and reset the cooldown, making the poller re-fetch that series on every tick for as long as the user kept reading it. Two tests guard this:TestUpsertPreservesLatestCheckedAtandTestPutDoesNotClobberLatestCheckedAt, the latter driving a real router PUT with a userscript-shaped body.updated_atnever moves on a latest-chapter bump. All chapter writes go throughStore.Get+Store.Upsert, so the existingCASEkeeps the stored timestamp when onlylatest_chapter_numchanges and the bookmark list does not reorder.TestRunOnceDoesNotReorderListasserts both the timestamp and theList()head position.Fetches use
bogdanfinn/tls-clientwith a Chrome profile. Plainnet/httpwas verified working against both sites on 2026-07-26, so this is not fixing an observed block — it is deliberate defence-in-depth against a future fingerprint-based one. The library is pure Go, soCGO_ENABLED=0, the static binary, and the distroless image are all unaffected. It does require the Go floor to move 1.23 → 1.24.checkOnevalidates before spending a request.series_urlis entirely client-supplied throughPUT /bookmarks/{key}, so without a guard the poller would issue GETs from the server's own network position to any URL a token holder writes. The check requires a known site and anhttpsURL with a non-empty host, and sits after the cooldown stamp so an unfetchable row is retried at cooldown pace rather than hot-looping.Config
Five new env vars, all with defaults sized for this deployment, all wired through
docker-compose.yml:LATEST_CHAPTER_POLL_ENABLED1LATEST_CHAPTER_POLL_COOLDOWN1h15mLATEST_CHAPTER_POLL_INTERVAL10mLATEST_CHAPTER_POLL_BATCH14LATEST_CHAPTER_POLL_STAGGER20sbatch × (cooldown / interval)= 84 series hold a true cooldown cadence at these defaults. Past that nothing breaks: the cadence stretches uniformly and the oldest-checked-first ordering keeps it fair. Bad values log and fall back rather than failing startup — the poller is an enhancement, and a typo in one of its knobs must not stop bookmark sync.Known limitation (accepted, documented)
The poller's
Store.Get+Store.Upsertis not wrapped in a single transaction. If a userscriptPUTcommits in the sub-millisecond window between the two, the poller writes back its stale re-read — reverting that progress and, since the storedlast_chapter_numnow differs, tripping theupdated_atCASEand reordering the list.Accepted rather than fixed for a single-user deployment: the window is one SELECT wide, the poller only writes when a chapter number actually changed, and the next read self-heals it. The alternative — a transactional read-modify-write — means moving or duplicating the
updated_atCASEthat four tests and the whole list-ordering invariant depend on. Recorded inCLAUDE.mdnext to the poller's architecture bullet so it is not a silent trap.Testing
-race;go vetclean;CGO_ENABLED=0static build anddocker compose buildboth pass on the bumpedgolang:1.24-alpine.fetcherinterface exists so tests inject a fake, and no test importstls-clientor reaches either manga site.&and&forms.newTestStoretakes theCREATE TABLEpath, so theALTER TABLEpath would otherwise be untested.latest is now Chapter 181anddue=1 checked=1;GET /bookmarksreturnedlatest_chapter_num: 181withupdated_atbyte-identical to the PUT that created the row — the no-reorder invariant confirmed against a live site, not just a fake.🤖 Generated with Claude Code