finished_at lands on series (0016 seeds it from the pre-flip finished bookmarks, then flips those bookmarks to archived), the poll gate and eligible count read the flag instead of a per-Reader vote, PUT rejects the finished status like any unknown value, and the web UI drops the Finished tab, badge and finish button. Novel merge rank is archived > reading. Per-Reader disagreement (one Reader keeps a finished Series in reading forever) is what the flag repairs; the cutover keeps polling state unchanged for every Series.
This commit is contained in:
@@ -47,8 +47,8 @@ type Bookmark struct {
|
||||
LatestChapter string `json:"latest_chapter"`
|
||||
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
||||
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
||||
// Status is the lifecycle bucket: reading, archived, or finished.
|
||||
// Archived series stay polled for new chapters; finished ones do not.
|
||||
// Status is the lifecycle bucket: reading or archived.
|
||||
// Archived series stay polled for new chapters.
|
||||
// Empty on the way in means "no opinion" — see Upsert.
|
||||
Status string `json:"status"`
|
||||
// Kind is the library bucket: manga or novel. Empty on the way in means
|
||||
@@ -214,10 +214,11 @@ const (
|
||||
)
|
||||
|
||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||
// Finished is not a bucket: it is a fact about the Series (series.finished_at),
|
||||
// never about a Reader's bookmark.
|
||||
const (
|
||||
StatusReading = "reading"
|
||||
StatusArchived = "archived"
|
||||
StatusFinished = "finished"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
@@ -619,9 +620,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
// bookmark is keyed (reader_id, site, series_id) (issue #22).
|
||||
b.Key = b.Site + ":" + b.SeriesID
|
||||
// An unrecognised bucket (a hand-edited row) would leave the row in no list
|
||||
// at all, so anything outside the three known buckets reads as the default
|
||||
// at all, so anything outside the two known buckets reads as the default
|
||||
// rather than being passed through.
|
||||
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
|
||||
if b.Status != StatusReading && b.Status != StatusArchived {
|
||||
b.Status = StatusReading
|
||||
}
|
||||
return b, nil
|
||||
@@ -1302,10 +1303,12 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
//
|
||||
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
|
||||
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
|
||||
// clause and the finished-only bucket. It never overrides an empty
|
||||
// series_url or the Bookmarks join — nothing to fetch, and no consumer for
|
||||
// the result — so those stay unconditional. Forced rows sort to the front of
|
||||
// the queue; the reader-count-then-age ordering among the rest is ADR-0003.
|
||||
// clause and a finished Series. It never overrides an empty series_url or the
|
||||
// Bookmarks join — nothing to fetch, and no consumer for the result — so
|
||||
// those stay unconditional, and it never clears the finish: nothing here
|
||||
// writes finished_at, and pending force clears itself when the pass stamps
|
||||
// the check timestamp. Forced rows sort to the front of the queue; the
|
||||
// reader-count-then-age ordering among the rest is ADR-0003.
|
||||
//
|
||||
// The reader_count ordering is the point of the split (ADR-0003): a series
|
||||
// shared by several readers is fetched once per due cycle, and the popular
|
||||
@@ -1313,14 +1316,15 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
// reader count, oldest-first keeps the poll fair when the backlog outgrows
|
||||
// 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).
|
||||
// at all. The userscript sorts its own queue the same way.
|
||||
//
|
||||
// Series with no series_url are skipped — there is nothing to fetch, which is
|
||||
// the same filter the userscript applies at L452. Series whose only bookmarks
|
||||
// are finished are skipped too: nothing more is coming, so fetching them only
|
||||
// burns requests. Archived bookmarks still count — knowing what a shelved
|
||||
// series is up to is the whole reason for archiving instead of deleting.
|
||||
// A series with no bookmarks at all never appears: the join excludes it.
|
||||
// the same filter the userscript applies before refreshing. A finished Series is
|
||||
// skipped unless forced: nothing more is coming, so fetching it only burns
|
||||
// requests (issue #157). Archived bookmarks still count — knowing what a
|
||||
// shelved series is up to is the whole reason for archiving instead of
|
||||
// deleting. A series with no bookmarks at all never appears: the join
|
||||
// excludes it.
|
||||
//
|
||||
// ceilingMs is the Sighting deferral ceiling (issue #103): a Series whose last
|
||||
// real Poll is older than it appears however recently it was sighted. That is
|
||||
@@ -1329,10 +1333,10 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
// decided here, from two facts the query already computes, so a Lane gains no
|
||||
// query per round: a Sighting younger than cutoffMs holds the Series back, but
|
||||
// only while COUNT(*) is 1. A Series a second Reader bookmarks is Polled on
|
||||
// schedule, so a wrong value the whole guild can see is corrected by a check
|
||||
// that was never postponed; on a solitary Series the only person a wrong value
|
||||
// reaches is the Reader who reported it. Whether the reporting Reader is
|
||||
// allowed to defer at all was settled when the Sighting was recorded — see
|
||||
// the schedule, so a wrong value the guild can see is corrected by a check
|
||||
// that was never postponed; on a solitary Series the only person a wrong
|
||||
// value reaches is the Reader who reported it. Whether the reporting Reader
|
||||
// is allowed to defer at all was settled when the Sighting was recorded — see
|
||||
// RecordSighting.
|
||||
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
||||
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
|
||||
@@ -1344,12 +1348,11 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
||||
AND s.series_url <> ''
|
||||
AND (s.latest_checked_at <= $2::bigint
|
||||
OR s.force_poll_at > s.latest_checked_at)
|
||||
AND (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)
|
||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||
s.force_poll_at
|
||||
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
||||
OR s.force_poll_at > s.latest_checked_at)
|
||||
AND (COUNT(*) > 1
|
||||
s.force_poll_at, s.finished_at
|
||||
HAVING (COUNT(*) > 1
|
||||
OR s.latest_sighted_at <= $2::bigint
|
||||
OR s.latest_checked_at <= $3::bigint
|
||||
OR s.force_poll_at > s.latest_checked_at)
|
||||
@@ -1371,14 +1374,18 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EligibleSeriesCount returns how many of a Site's Series still have at least
|
||||
// one bookmark outside the finished bucket. It is the denominator of the
|
||||
// Lane's pace (issue #100): the effective gap is the smaller of the registry
|
||||
// gap and one hour divided by this count, so Series that will never be Polled
|
||||
// do not make the Lane faster than it needs to be, and counting every eligible
|
||||
// Series rather than only those currently due keeps the pace steady — the
|
||||
// single worst moment to be fastest is startup, when everything is due at
|
||||
// once.
|
||||
// EligibleSeriesCount returns how many of a Site's Series are not finished —
|
||||
// the flag, never a Reader vote. It is the denominator of the Lane's pace
|
||||
// (issue #100): the effective gap is the smaller of the registry gap and one
|
||||
// hour divided by this count, so Series that will never be Polled do not make
|
||||
// the Lane faster than it needs to be, and counting every eligible Series
|
||||
// rather than only those currently due keeps the pace steady — the single
|
||||
// worst moment to be fastest is startup, when everything is due at once.
|
||||
//
|
||||
// The deliberate asymmetry with DueForLatestCheck's WHERE: a forced Series
|
||||
// is due but never admitted here, because a forced pass must not speed up
|
||||
// every other fetch on the Site — one impassioned press is not a reason to
|
||||
// hammer the Site (issue #157).
|
||||
func (s *Store) EligibleSeriesCount(site string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||
@@ -1386,8 +1393,8 @@ func (s *Store) EligibleSeriesCount(site string) (int, error) {
|
||||
FROM series s
|
||||
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||
WHERE s.site = $1
|
||||
AND s.finished_at = 0
|
||||
GROUP BY s.site, s.series_id
|
||||
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
||||
) e`, site).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count eligible series %s: %w", site, err)
|
||||
|
||||
Reference in New Issue
Block a user