62772e1eaa
Adds a background goroutine to the backend that re-checks each bookmarked series' newest published chapter on its own schedule, so `latest_chapter` stays 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` / `backgroundRefreshLatest` logic, unchanged. `userscript/manga-bookmark.user.js` is byte-identical to `main`.
## How it works
One ticker goroutine in the same binary. Each wake it asks SQLite for bookmarks whose `latest_checked_at` has 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 through `Store.Get` + `Store.Upsert`. Every failure path logs and moves on.
Two independent clocks:
- **cooldown** — how long one bookmark rests between checks, enforced by the `WHERE` clause in `Store.DueForLatestCheck`, not by a timer.
- **interval** — how often the goroutine wakes and looks.
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_at` is deliberately absent from the `Bookmark` struct and from `bookmarkColumns`.** `PUT /bookmarks/{key}` decodes a whole `Bookmark` and `Upsert` writes 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: `TestUpsertPreservesLatestCheckedAt` and `TestPutDoesNotClobberLatestCheckedAt`, the latter driving a real router PUT with a userscript-shaped body.
**`updated_at` never moves on a latest-chapter bump.** All chapter writes go through `Store.Get` + `Store.Upsert`, so the existing `CASE` keeps the stored timestamp when only `latest_chapter_num` changes and the bookmark list does not reorder. `TestRunOnceDoesNotReorderList` asserts both the timestamp and the `List()` head position.
**Fetches use `bogdanfinn/tls-client` with a Chrome profile.** Plain `net/http` was 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, so `CGO_ENABLED=0`, the static binary, and the distroless image are all unaffected. It does require the Go floor to move 1.23 → 1.24.
**`checkOne` validates before spending a request.** `series_url` is entirely client-supplied through `PUT /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 an `https` URL 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`:
| Variable | Default | Meaning |
| --- | --- | --- |
| `LATEST_CHAPTER_POLL_ENABLED` | `1` | Kill switch |
| `LATEST_CHAPTER_POLL_COOLDOWN` | `1h` | Per series, floored at `15m` |
| `LATEST_CHAPTER_POLL_INTERVAL` | `10m` | How often to wake |
| `LATEST_CHAPTER_POLL_BATCH` | `14` | Series per wake |
| `LATEST_CHAPTER_POLL_STAGGER` | `20s` | Delay between fetches in a batch |
`batch × (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.Upsert` is not wrapped in a single transaction. If a userscript `PUT` commits in the sub-millisecond window between the two, the poller writes back its stale re-read — reverting that progress and, since the stored `last_chapter_num` now differs, tripping the `updated_at` `CASE` and 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_at` `CASE` that four tests and the whole list-ordering invariant depend on. Recorded in `CLAUDE.md` next to the poller's architecture bullet so it is not a silent trap.
## Testing
- Full suite green, including `-race`; `go vet` clean; `CGO_ENABLED=0` static build and `docker compose build` both pass on the bumped `golang:1.24-alpine`.
- No test touches the network: the `fetcher` interface exists so tests inject a fake, and no test imports `tls-client` or reaches either manga site.
- Extraction is fixture-driven against markup trimmed from real pages (2026-07-26), including a Cloudflare challenge page, cross-series chapter links, decimal chapters, and both raw `&` and `&` forms.
- Poller tests cover the no-reorder invariant, cooldown enforcement across passes, batch limiting, one bad series not stalling a batch, downward correction on a retracted chapter, cancelled contexts, and all four failure shapes still consuming the cooldown.
- Migration from a pre-column database has its own test — `newTestStore` takes the `CREATE TABLE` path, so the `ALTER TABLE` path would otherwise be untested.
- **Live smoke test:** real server, real fetch of asurascans.com. Log showed `latest is now Chapter 181` and `due=1 checked=1`; `GET /bookmarks` returned `latest_chapter_num: 181` with `updated_at` byte-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](https://claude.com/claude-code)
Reviewed-on: #2
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
336 lines
12 KiB
Go
336 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Bookmark is one tracked series, keyed "<site>:<series_id>" across both sites.
|
|
//
|
|
// LastChapter* is the user's read progress; LatestChapter* is the newest
|
|
// chapter the site has published, captured opportunistically by the userscript.
|
|
type Bookmark struct {
|
|
Key string `json:"key"`
|
|
Site string `json:"site"`
|
|
SeriesID string `json:"series_id"`
|
|
Title string `json:"title"`
|
|
SeriesURL string `json:"series_url"`
|
|
Cover string `json:"cover"`
|
|
LastChapter string `json:"last_chapter"`
|
|
LastChapterNum float64 `json:"last_chapter_num"`
|
|
LastChapterURL string `json:"last_chapter_url"`
|
|
Favorite bool `json:"favorite"`
|
|
LatestChapter string `json:"latest_chapter"`
|
|
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
|
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
|
}
|
|
|
|
// HasNewChapter reports whether the site has published past the read point.
|
|
// A nil LatestChapterNum means nothing has been captured yet, which is not the
|
|
// same as "nothing new".
|
|
func (b Bookmark) HasNewChapter() bool {
|
|
return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum
|
|
}
|
|
|
|
// ContinueURL is where the Continue button points: the chapter last read, or
|
|
// the series page when no chapter URL was ever captured.
|
|
func (b Bookmark) ContinueURL() string {
|
|
if b.LastChapterURL != "" {
|
|
return b.LastChapterURL
|
|
}
|
|
return b.SeriesURL
|
|
}
|
|
|
|
const schema = `
|
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
|
key TEXT PRIMARY KEY,
|
|
site TEXT NOT NULL,
|
|
series_id TEXT NOT NULL,
|
|
title TEXT,
|
|
series_url TEXT,
|
|
cover TEXT,
|
|
last_chapter TEXT,
|
|
last_chapter_num REAL,
|
|
last_chapter_url TEXT,
|
|
favorite INTEGER NOT NULL DEFAULT 0,
|
|
latest_chapter TEXT NOT NULL DEFAULT '',
|
|
latest_chapter_num REAL,
|
|
latest_checked_at INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER NOT NULL
|
|
);`
|
|
|
|
// The columns above that databases created before them will be missing.
|
|
// SQLite has no ADD COLUMN IF NOT EXISTS, so each is added only when absent.
|
|
var addedColumns = []struct{ name, ddl string }{
|
|
{"favorite", `ALTER TABLE bookmarks ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0`},
|
|
{"latest_chapter", `ALTER TABLE bookmarks ADD COLUMN latest_chapter TEXT NOT NULL DEFAULT ''`},
|
|
{"latest_chapter_num", `ALTER TABLE bookmarks ADD COLUMN latest_chapter_num REAL`},
|
|
// When the server last looked at this series, unix ms; 0 means never, and
|
|
// sorts first so a new bookmark is picked up on the next tick with no
|
|
// special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked.
|
|
{"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`},
|
|
}
|
|
|
|
const bookmarkColumns = `key, site, series_id, title, series_url, cover,
|
|
last_chapter, last_chapter_num, last_chapter_url,
|
|
favorite, latest_chapter, latest_chapter_num, updated_at`
|
|
|
|
// Store is the SQLite-backed bookmark store.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
|
func OpenStore(path string) (*Store, error) {
|
|
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
|
// concurrent requests; a single writer connection keeps writes serialized.
|
|
dsn := path
|
|
if !strings.Contains(dsn, "?") {
|
|
dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
|
|
}
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
if _, err := db.Exec(schema); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("apply schema: %w", err)
|
|
}
|
|
if err := migrateColumns(db); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrate schema: %w", err)
|
|
}
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// migrateColumns brings a pre-existing bookmarks table up to the current
|
|
// schema. Safe to run on every start: columns already present are skipped.
|
|
func migrateColumns(db *sql.DB) error {
|
|
have, err := existingColumns(db, "bookmarks")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, c := range addedColumns {
|
|
if _, ok := have[c.name]; ok {
|
|
continue
|
|
}
|
|
if _, err := db.Exec(c.ddl); err != nil {
|
|
return fmt.Errorf("add column %q: %w", c.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func existingColumns(db *sql.DB, table string) (map[string]struct{}, error) {
|
|
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s columns: %w", table, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string]struct{}{}
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
return nil, fmt.Errorf("scan column name: %w", err)
|
|
}
|
|
out[name] = struct{}{}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// scanBookmark reads one row in bookmarkColumns order, translating SQLite's
|
|
// integer bool and nullable latest_chapter_num into Go types.
|
|
//
|
|
// The optional columns are read through Null* types because rows predating
|
|
// this code (or written by hand) may hold NULL where the app only ever writes
|
|
// zero values. Only latest_chapter_num distinguishes the two: everywhere else
|
|
// NULL and the zero value mean the same thing to clients.
|
|
func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
|
var (
|
|
b Bookmark
|
|
title, seriesURL, cover sql.NullString
|
|
lastChapter, lastChapterURL, latestChapter sql.NullString
|
|
lastChapterNum, latestChapterNum sql.NullFloat64
|
|
favorite sql.NullInt64
|
|
)
|
|
if err := scan(
|
|
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
|
|
&lastChapter, &lastChapterNum, &lastChapterURL,
|
|
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt,
|
|
); err != nil {
|
|
return Bookmark{}, err
|
|
}
|
|
b.Title = title.String
|
|
b.SeriesURL = seriesURL.String
|
|
b.Cover = cover.String
|
|
b.LastChapter = lastChapter.String
|
|
b.LastChapterNum = lastChapterNum.Float64
|
|
b.LastChapterURL = lastChapterURL.String
|
|
b.Favorite = favorite.Int64 != 0
|
|
b.LatestChapter = latestChapter.String
|
|
if latestChapterNum.Valid {
|
|
b.LatestChapterNum = &latestChapterNum.Float64
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// Close releases the underlying database handle.
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
// List returns every bookmark, newest activity first.
|
|
func (s *Store) List() ([]Bookmark, error) {
|
|
rows, err := s.db.Query(`SELECT ` + bookmarkColumns + `
|
|
FROM bookmarks
|
|
ORDER BY updated_at DESC`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query bookmarks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Bookmark{}
|
|
for rows.Next() {
|
|
b, err := scanBookmark(rows.Scan)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan bookmark: %w", err)
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Get returns one bookmark by key. A missing key is not an error: ok is false
|
|
// and err is nil. UI mutations read-modify-write through this so they preserve
|
|
// the fields they do not touch.
|
|
func (s *Store) Get(key string) (Bookmark, bool, error) {
|
|
b, err := scanBookmark(s.db.QueryRow(
|
|
`SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, key).Scan)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Bookmark{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Bookmark{}, false, fmt.Errorf("get %q: %w", key, err)
|
|
}
|
|
return b, true, nil
|
|
}
|
|
|
|
// Upsert inserts or replaces a bookmark by key (last-write-wins) and returns
|
|
// the row as actually stored.
|
|
//
|
|
// b.UpdatedAt is only a candidate: it is applied when the row is new or when
|
|
// last_chapter_num changes, and otherwise the stored value is kept. Clients
|
|
// order their list by updated_at, so favoriting a series or recording a newly
|
|
// published chapter must not disturb that order — only real reading progress
|
|
// does. Callers must therefore use the returned bookmark, not the argument.
|
|
func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Bookmark{}, fmt.Errorf("begin %q: %w", b.Key, err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var latestNum any
|
|
if b.LatestChapterNum != nil {
|
|
latestNum = *b.LatestChapterNum
|
|
}
|
|
|
|
// IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column
|
|
// is the stored row and excluded.* is the incoming one; a brand-new key
|
|
// never reaches this clause, so it keeps the fresh timestamp from VALUES.
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO bookmarks (`+bookmarkColumns+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
site=excluded.site, series_id=excluded.series_id, title=excluded.title,
|
|
series_url=excluded.series_url, cover=excluded.cover,
|
|
last_chapter=excluded.last_chapter, last_chapter_num=excluded.last_chapter_num,
|
|
last_chapter_url=excluded.last_chapter_url,
|
|
favorite=excluded.favorite,
|
|
latest_chapter=excluded.latest_chapter,
|
|
latest_chapter_num=excluded.latest_chapter_num,
|
|
updated_at=CASE
|
|
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
|
|
THEN excluded.updated_at
|
|
ELSE bookmarks.updated_at
|
|
END`,
|
|
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
|
|
b.LastChapter, b.LastChapterNum, b.LastChapterURL,
|
|
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt); err != nil {
|
|
return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
|
|
}
|
|
|
|
stored, err := scanBookmark(tx.QueryRow(
|
|
`SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, b.Key).Scan)
|
|
if err != nil {
|
|
return Bookmark{}, fmt.Errorf("read back %q: %w", b.Key, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Bookmark{}, fmt.Errorf("commit %q: %w", b.Key, err)
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
// Delete removes a bookmark by key. Deleting a missing key is not an error.
|
|
func (s *Store) Delete(key string) error {
|
|
if _, err := s.db.Exec(`DELETE FROM bookmarks WHERE key = ?`, key); err != nil {
|
|
return fmt.Errorf("delete %q: %w", key, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DueForLatestCheck returns bookmarks whose server-side latest-chapter check has
|
|
// aged past cutoffMs, least-recently-checked first, at most limit of them.
|
|
//
|
|
// Oldest-first is what keeps the poller fair when the backlog outgrows its
|
|
// throughput: the most neglected series is always next, so a large collection
|
|
// refreshes uniformly slower rather than leaving a tail that never refreshes at
|
|
// all. The userscript sorts its own queue the same way (L453).
|
|
//
|
|
// Bookmarks with no series_url are skipped — there is nothing to fetch, which
|
|
// is the same filter the userscript applies at L452.
|
|
func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) {
|
|
rows, err := s.db.Query(`SELECT `+bookmarkColumns+`
|
|
FROM bookmarks
|
|
WHERE series_url IS NOT NULL AND series_url <> ''
|
|
AND latest_checked_at <= ?
|
|
ORDER BY latest_checked_at ASC
|
|
LIMIT ?`, cutoffMs, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query due bookmarks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Bookmark{}
|
|
for rows.Next() {
|
|
b, err := scanBookmark(rows.Scan)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan due bookmark: %w", err)
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// MarkLatestChecked records that the server looked at key at ts, whatever the
|
|
// look turned up. Marking a missing key is not an error: the row may have been
|
|
// deleted while a fetch was in flight.
|
|
//
|
|
// This is the one write that does not go through Upsert, and the column is kept
|
|
// out of bookmarkColumns on purpose. PUT /bookmarks/{key} decodes a whole
|
|
// Bookmark from the client and Upsert writes 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 every tick for as
|
|
// long as the user kept reading it.
|
|
func (s *Store) MarkLatestChecked(key string, ts int64) error {
|
|
if _, err := s.db.Exec(
|
|
`UPDATE bookmarks SET latest_checked_at = ? WHERE key = ?`, ts, key); err != nil {
|
|
return fmt.Errorf("mark checked %q: %w", key, err)
|
|
}
|
|
return nil
|
|
}
|