feat(latest): prefetch kagane covers during polling

Add an optional cover fetcher to the latest-chapter poller and wire the shared browser into it. Each due Kagane series now checks persistent storage, fetches a missing cover once, and keeps chapter polling and cooldown bookkeeping independent when cover work fails.\n\nCentralize Kagane image-id and content-type validation so prefetched bytes cannot poison the on-first-view fallback, and cover the missing, cached, failure, nil-fetcher, invalid-type, and non-Kagane paths with deterministic poll-cycle tests.\n\nRefs #45
This commit is contained in:
2026-08-09 07:50:55 +07:00
parent 84cfd1b2c1
commit 639ca6ffd1
5 changed files with 258 additions and 15 deletions
+26 -2
View File
@@ -142,14 +142,35 @@ func (b Bookmark) Initial() string {
// the userscript stores for that site.
var kaganeCoverRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`)
// KaganeImageID extracts the validated image id from the cover URL recorded by
// the userscript.
func KaganeImageID(cover string) (string, bool) {
m := kaganeCoverRe.FindStringSubmatch(cover)
if m == nil {
return "", false
}
return m[1], true
}
// IsKaganeCoverContentType reports whether a fetched response is safe to store
// and serve as a cover.
func IsKaganeCoverContentType(contentType string) bool {
switch contentType {
case "image/webp", "image/jpeg", "image/png", "image/avif", "image/gif":
return true
default:
return false
}
}
// CoverURL is the src the web UI puts in an <img>. For every site but kagane
// that is Cover as stored. kagane serves its images behind a Cloudflare
// challenge *and* with `cross-origin-resource-policy: same-origin`, so no page
// on another origin can load one however it asks (verified 2026-08-08); those
// go through the backend's own proxy instead.
func (b Bookmark) CoverURL() string {
if m := kaganeCoverRe.FindStringSubmatch(b.Cover); m != nil {
return "/img/kagane/" + m[1]
if imageID, ok := KaganeImageID(b.Cover); ok {
return "/img/kagane/" + imageID
}
return b.Cover
}
@@ -551,6 +572,9 @@ func (s *Store) GetKaganeCover(imageID string) ([]byte, string, bool, error) {
// PutKaganeCover persists one fetched cover. Image ids are immutable, so a
// later fetch cannot replace the bytes already made durable.
func (s *Store) PutKaganeCover(imageID string, body []byte, contentType string) error {
if !IsKaganeCoverContentType(contentType) {
return fmt.Errorf("put kagane cover %q: unsupported content type %q", imageID, contentType)
}
if _, err := s.db.Exec(`
INSERT INTO covers (image_id, body, content_type)
VALUES ($1, $2, $3)