package latest import ( "context" "errors" "log" "sync" "time" "bookmarkmanager/backend/internal/store" ) // acquireTimeout bounds one creation-time acquisition end to end: the series // page plus the cover bytes. Nothing is waiting on it — the Reader's write has // already returned — so this only stops a stalled Site from holding a // goroutine and a connection open forever. const acquireTimeout = 45 * time.Second // Acquirer gives a Series its Latest Chapter and its Cover the moment the // first Bookmark creates it, instead of leaving the Reader to wait out the // poll queue — which is ordered by Reader count, so a Series with one Reader // sits behind every popular one (ADR-0007). // // Both facts come from a single series-page fetch, which is also why no // client-supplied cover hint is worth accepting: the page has to be fetched // for the chapter signal regardless, so a hint would save no request while // adding a client-controlled input to a server-side fetch. // // Every failure path is "log and move on". The Bookmark, its progress and its // Latest Chapter are already committed; a Site that is down or a Cover that // cannot be produced must not disturb any of them, and the Series is simply // left blank until the poll's own cover pass (#61) fills it. type Acquirer struct { Store *store.Store // Fetch retrieves the series page over plain TLS. Nil with a nil // BrowserFetch disables acquisition entirely. Fetch Fetcher // BrowserFetch retrieves kagane and novelfull pages through the browser // sidecar, the only thing that clears their Cloudflare challenge. The // per-site fallback policy lives in fetcherFor. Nil leaves those Sites // unacquired when no fallback applies. BrowserFetch Fetcher // Covers retrieves the cover bytes. Nil leaves the Cover blank and the // chapter half working. Covers CoverBytesFetcher // BrowserCoverFetch retrieves browser-claimed cover bytes through the // sidecar. Nil leaves those Covers blank; nothing falls back to a plain // fetch, which would only ever retrieve a challenge page. BrowserCoverFetch BrowserCoverFetcher // Ctx cancels in-flight acquisitions at shutdown. A hook signature has // nowhere to pass one, so it lives here; nil means context.Background. Ctx context.Context inflight sync.WaitGroup } // acquireSlots caps how many creation-time fetches run at once. A Reader whose // userscript bulk-syncs creates many Series at once, and a burst of // simultaneous requests from one server IP is the traffic shape most likely to // move that IP's bot score — the same reason the poller staggers its batch. var acquireSlots = make(chan struct{}, 2) // Acquire starts one acquisition and returns immediately: a Reader's bookmark // action may not block on a third-party Site's latency, nor fail with it. It // is the store's OnSeriesCreated hook, so it only ever runs for a Series no // Reader had bookmarked before. func (a *Acquirer) Acquire(sr store.Series) { a.inflight.Add(1) go func() { defer a.inflight.Done() defer func() { if r := recover(); r != nil { log.Printf("acquire %q: recovered from panic: %v", sr.Key(), r) } }() parent := a.Ctx if parent == nil { parent = context.Background() } select { case acquireSlots <- struct{}{}: defer func() { <-acquireSlots }() case <-parent.Done(): return } ctx, cancel := context.WithTimeout(parent, acquireTimeout) defer cancel() a.acquire(ctx, sr) }() } // Wait blocks until every started acquisition has finished. It exists for // tests: an asynchronous side effect is otherwise unobservable without // polling for it. func (a *Acquirer) Wait() { a.inflight.Wait() } func (a *Acquirer) acquire(ctx context.Context, sr store.Series) { if a.Fetch == nil && a.BrowserFetch == nil { return } facts, err := readSeriesPage(ctx, sr.Site, sr.SeriesURL, a.BrowserFetch, a.Fetch) if err != nil { switch { case errors.Is(err, errNotFetchable): // series_url arrives in a client-supplied PUT body, so without the // gate a token-holder chooses what the server fetches from its own // network position. log.Printf("acquire %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL) case errors.Is(err, errNoFetcher): log.Printf("acquire %q: no fetcher for site %q", sr.Key(), sr.Site) default: log.Printf("acquire %q: %v", sr.Key(), err) } return } // This page just served the same purpose a poll tick would have; without // the stamp the row stays due and the poller refetches it immediately. // // Stamped after success — the reverse of the poller, which stamps before // the fetch: the Reader is here, watching the Series they just created, so // a failed acquisition must leave the row due for a fast retry rather than // consuming the rest. The stamp happens even when the page read // succeeded but produced no facts to persist. if err := a.Store.MarkLatestChecked(sr.Site, sr.SeriesID, time.Now().UnixMilli()); err != nil { log.Printf("acquire %q: mark checked: %v", sr.Key(), err) } if facts.HasLatest { if err := a.Store.SetLatestChapter(sr.Site, sr.SeriesID, facts.Latest.Label, facts.Latest.Num); err != nil { log.Printf("acquire %q: set latest chapter: %v", sr.Key(), err) } } if !facts.HasCover { return } bytes, contentType, err := fetchCoverBytes(ctx, facts.Cover, a.BrowserCoverFetch, a.Covers) if err != nil { log.Printf("acquire %q: fetch cover %s: %v", sr.Key(), facts.Cover, err) return } if err := a.Store.SetSeriesCover(sr.Site, sr.SeriesID, facts.Cover, bytes, contentType); err != nil { log.Printf("acquire %q: persist cover: %v", sr.Key(), err) } }