Implements spec #135 (spec 2 of 4, derived from wayfinder map #114; decisions settled in #120/#121/#125/#131). Blocked-by #134 is merged, so this lands on `main`. Four owner actions the dashboard can now perform, one ticket each: - **#149** — Latest Chapter correction: one numeric input, overwritten by the next machine write. - **#151** — Series URL repair: owner-typed, gated by the poller's own fetch gate. - **#150 / #153 / #154** — Cover replacement: addresses derived from bytes (`#150`), a Forced Poll replaces the Cover while an ordinary pass still only fills a blank one (`#153`), and byte reclamation is one guarded helper, file first / covers row last (`#154`). - **#155** — Orphan removal: one Series at a time, with the foreign key as the guard. Plus **#152** — Latest Chapter provenance: one derived line naming the actor class, so an owner can tell a hand-edited number from a machine read. - Migration `0015_latest_correction.sql` adds the correction/provenance columns; `0009` now derives cover addresses from bytes. - ADR `0014-cover-addresses-from-bytes.md` records the address scheme. Backend tests cover the store, poller, admin handlers, and web routes (`go test ./...`, needs Docker). Reviewed-on: #156 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #156.
This commit is contained in:
@@ -98,6 +98,23 @@ func (p *Poller) fillBlankCover(ctx context.Context, sr store.Series, cover stri
|
||||
}()
|
||||
}
|
||||
|
||||
// replaceCover is the Forced Poll's Cover path: the owner asked to accept the
|
||||
// page as it now stands, so where fillBlankCover leaves a non-blank Cover
|
||||
// alone (ADR-0007) this writes through whatever the page's Cover URL answers
|
||||
// with, whether one exists or not. The accepted consequence (issue #135):
|
||||
// refreshing the Cover and re-reading the chapters are one act — there is no
|
||||
// Cover-only refetch.
|
||||
func (p *Poller) replaceCover(ctx context.Context, sr store.Series, cover string) {
|
||||
if cover == "" {
|
||||
return
|
||||
}
|
||||
p.coverWG.Add(1)
|
||||
go func() {
|
||||
defer p.coverWG.Done()
|
||||
p.storeCover(ctx, sr, cover)
|
||||
}()
|
||||
}
|
||||
|
||||
// prefetchCover heals Series that already carry a third-party source URL but
|
||||
// no stored address — the state left by client-supplied covers before
|
||||
// acquisition moved server-side. Every Site takes the same path; fetchCoverBytes
|
||||
@@ -122,17 +139,42 @@ func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) {
|
||||
p.storeCover(ctx, sr, sr.Cover)
|
||||
}
|
||||
|
||||
// storeCover fetches bytes for sourceURL and points the Series at them. Every
|
||||
// failure is logged against the Series and swallowed so the chapter poll
|
||||
// cannot see it.
|
||||
// storeCover fetches bytes for sourceURL and points the Series at them: a
|
||||
// fill-only write for an ordinary pass, a write-through for a forced one
|
||||
// (issue #135). Every failure is logged against the Series and swallowed so
|
||||
// the chapter poll cannot see it.
|
||||
func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL string) {
|
||||
bytes, contentType, err := fetchCoverBytes(ctx, sourceURL, p.CoverFetch, p.CoverBytesFetch)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: fetch cover %s: %v", sr.Key(), sourceURL, err)
|
||||
return
|
||||
}
|
||||
if err := p.Store.SetSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType); err != nil {
|
||||
if !sr.Forced {
|
||||
if err := p.Store.SetSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType); err != nil {
|
||||
log.Printf("latest poll %q: persist cover: %v", sr.Key(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The forced write replaces whether or not a Cover exists, and the row
|
||||
// then tells the three outcomes apart: a blank filled, identical artwork
|
||||
// re-served — an honest no-op — or a replacement whose previous address
|
||||
// is stranded and reclaimed below. A failed reclaim is logged and the
|
||||
// stranded bytes stay served until a later call reclaims them.
|
||||
previous, current, err := p.Store.ReplaceSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: persist cover: %v", sr.Key(), err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case previous == "":
|
||||
log.Printf("latest poll %q: cover filled at %s", sr.Key(), current)
|
||||
case previous == current:
|
||||
log.Printf("latest poll %q: cover unchanged, the site re-serves the same bytes", sr.Key())
|
||||
default:
|
||||
if err := p.Store.ReclaimCover(previous); err != nil {
|
||||
log.Printf("latest poll %q: reclaim cover %s: %v", sr.Key(), previous, err)
|
||||
}
|
||||
log.Printf("latest poll %q: cover replaced %s -> %s", sr.Key(), previous, current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +663,12 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOut
|
||||
p.healCover(ctx, sr)
|
||||
// Cover fill is independent of the chapter signal: a page that lost its
|
||||
// chapter list may keep its og:image, and a blank Series heals either way.
|
||||
p.fillBlankCover(ctx, sr, facts.Cover)
|
||||
// A forced pass writes the Cover through the replace path instead.
|
||||
if sr.Forced {
|
||||
p.replaceCover(ctx, sr, facts.Cover)
|
||||
} else {
|
||||
p.fillBlankCover(ctx, sr, facts.Cover)
|
||||
}
|
||||
if !facts.HasLatest {
|
||||
// Most likely a challenge page or a layout change. Either way the row is
|
||||
// already stamped, so this waits out a rest instead of hot-looping.
|
||||
@@ -710,7 +757,7 @@ func (p *Poller) waitCovers() {
|
||||
p.coverWG.Wait()
|
||||
}
|
||||
|
||||
// fetchableSeriesURL reports whether site is a Site the registry knows and
|
||||
// FetchableSeriesURL reports whether site is a Site the registry knows and
|
||||
// seriesURL is safe to hand to a fetcher: an https URL whose host matches the
|
||||
// Site's pinned hostname exactly. series_url comes from client-supplied PUT
|
||||
// bodies, so this is a defence against the poller being used to probe
|
||||
@@ -719,7 +766,11 @@ func (p *Poller) waitCovers() {
|
||||
// browser Site guards a control that executes JavaScript and carries cookies,
|
||||
// a parser Site guards a wasted request — but the rule is one rule, from the
|
||||
// registry.
|
||||
func fetchableSeriesURL(site, seriesURL string) bool {
|
||||
//
|
||||
// The owner's series URL repair (issue #151) is a second caller: the web
|
||||
// layer validates with this same gate before storing a repair, so there is
|
||||
// never a second copy of it.
|
||||
func FetchableSeriesURL(site, seriesURL string) bool {
|
||||
s, known := sites[site]
|
||||
if !known {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user