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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user