14 KiB
Discovery: wider candidate pool — design
Status: approved design, not implemented. Date: 2026-10-25. Builds on the
deterministic Discovery Scout (docs/discovery.md,
vision/discovery-scout.md).
1. Goal
Today Discover can only suggest titles from the curated Greatest catalog plus AniList relations (sequels, spin-offs) of Greatest and library titles, and only when those are already in the local metadata cache. A run makes no network call. Users run out of new suggestions quickly.
This change widens the candidate pool with four seed kinds, fetched server-side from AniList:
- Community recommendations — "readers who liked X recommend Y" for the user's anchor titles.
- Same creator — other works by the story/art authors of anchor titles.
- Top in your tags — well-rated titles in the user's strongest genres/tags.
- Fresh — recently started or currently releasing, trending titles.
Success: suggestions the user did not already know, each with a traceable reason and evidence link, without exceeding AniList's rate limits or making the reader noticeably slower.
Unchanged rules from Phase A
- Personalized discovery and reading activity stay separate opt-ins, both off by default; feedback stays private to the account.
- Discovery never edits the scraper queue, library or collections and never downloads anything.
- Every suggestion carries evidence; missing data stays missing.
- The reader is the only writer of discovery state; no new Compose service, no database, no LLM.
- All fetching runs in the reader container, never in the browser.
- The repository is public: no host names, paths or personal seeds in code, docs or fixtures.
Non-goals
- No other metadata provider (AniList's API covers all four seed kinds).
- No LLM rationale or rerank (vision Phase C).
- No edition finding or queueing from a suggestion (vision Phase B).
- No worker container; it is only a fallback if section 7's latency check fails.
2. Components
| Unit | Responsibility | Depends on |
|---|---|---|
discovery/anilist.js (new) |
GraphQL queries for the four seed kinds and for a title by ID; one shared rate limiter; schema check and normalization of responses | fetch, discovery/cache.js |
discovery/cache.js (new) |
data/discovery-cache/: atomic writes, TTL per entry kind, size cap with oldest-first pruning |
filesystem |
discovery/harvest.js (new) |
Anchor selection per user, request plan, budgets, single-flight run state, stop reasons | anilist.js, cache.js, db (read-only) |
discovery/engine.js (changed) |
Builds candidates from Greatest, relations and the harvest cache; new score parts, evidence kinds, slot mix, isAdult filter |
cache.js (read-only) |
server.js (changed) |
Nightly scheduler, admin "Harvest now", per-user "Refresh my suggestions", admin status fields; route handlers stay thin | harvest.js, engine.js, discovery/store.js |
public/discover.* (changed) |
Refresh button states, new reason labels, fetch date in evidence, admin harvest panel | reader API |
The existing discovery/store.js (atomic discovery.json, .prev recovery,
fail-closed on corruption) is reused unchanged. The reader keeps reading the
scraper's AniList cache read-only for relations; the harvester never writes into
data/scraper/.
2.1 discovery/anilist.js
Queries (all type: MANGA, unauthenticated, https://graphql.anilist.co):
| Kind | Query | Page size |
|---|---|---|
title |
Media(id) with the fields of scraper/metadata/anilist.js plus isAdult averageScore popularity trending tags { name rank isAdult } recommendations staff |
1 |
recs |
Media(id).recommendations(sort: RATING_DESC) → mediaRecommendation + rating |
25 |
creator |
Staff(id).staffMedia(type: MANGA, sort: POPULARITY_DESC) |
25 |
tagTop |
Page.media(tag_in or genre_in: [one tag], sort: SCORE_DESC, popularity_greater: 1000) |
25 |
fresh |
Page.media(status: RELEASING, startDate_greater: <today − 2 years>, sort: TRENDING_DESC) |
50 |
- Rate limiter: at most 25 requests per minute, one at a time, shared by all harvests in the process. AniList allows more, so this stays below its limit with headroom.
- Normalized record:
{ anilistId, title, altTitles, format, isAdult, status, year, averageScore, popularity, trending, tags[{name, rank}], authors[{staffId, name, role}], coverUrl, siteUrl, fetchedAt }. Each list response becomes a list of{ anilistId, rating? }plus one record per entry it contains. - Formats: only
MANGAandONE_SHOTare kept; novels and anime are dropped. - Response check: responses are checked against a schema before they are cached; an invalid one counts as a failed request. Titles, descriptions and tags are treated as untrusted text: stored as data, escaped when shown.
2.2 discovery/cache.js
- Location:
data/discovery-cache/<kind>/<key>.json. Every entry has{ schema: 1, kind, key, fetchedAt, data }. - Lifetime:
titleandrecsentries live 30 days,creator30 days,tagTopandfresh7 days. An expired entry is still used for scoring until it is refreshed. Only the harvest checks the TTL. - Size cap: 5,000 files. On write, the oldest
fetchedAtentries are pruned first. - Writes: temporary file then rename, as in the existing stores. A corrupt entry is ignored and deleted.
2.3 discovery/harvest.js
Anchors per opted-in user. Each anchor has a weight; the list is deduplicated by AniList ID and capped at the 15 strongest:
| Source | Weight |
|---|---|
Greatest vote flame |
5 |
Greatest vote good |
3 |
Discover feedback interested |
4 |
Discover feedback maybe |
2 |
| Completed chapter in a library title (only with reading activity opted in) | 3 |
| Owned library title with an AniList ID | 1 |
User's top tags. The 3 tags with the highest summed anchor weight, using
the anchors' cached title records (tag rank ≥ 60, no adult tags unless the
user enabled mature titles).
Request plan, in this order:
titlefor anchors that are not cached.recsfor each anchor.creatorfor the story/art staff of anchors weighted 3 or more.tagTopfor each top tag.- One global
freshlist.
Requests whose cache entry is still within its TTL are skipped. Then follow-up
title requests run for new candidates that are missing tags or isAdult, up
to the remaining budget.
Budgets: nightly harvest 300 requests, per-user refresh 60 requests. When the budget runs out, the plan stops; whatever was cached so far is still used.
Run state: one harvest at a time (single-flight).
- A queued per-user refresh waits behind a running harvest; a second refresh
for the same user while one is queued returns
409. - The run record is persisted in
discovery.jsonharvest:{ id, kind: nightly|admin|user, user?, status, startedAt, finishedAt, requests, budget, stopReason }.
3. Data flow
anchors ─▶ request plan ─▶ AniList (rate-limited) ─▶ discovery-cache
│
Greatest + scraper AniList cache (relations) ─────────────┤
▼
engine.generate() ─▶ discovery.json (atomic publish)
Harvest and scoring are separate steps. Scoring is local and never touches the network, so a failed harvest means older candidates, never an empty or broken feed.
- After a nightly or admin harvest: re-score every opted-in user.
- After a per-user refresh: re-score only that user. The other users' feed snapshots are carried over unchanged into the same publish.
4. Scoring
engine.generate() builds the candidate map from Greatest, relations, and every
record referenced by the harvest cache, limited to 2,000 candidates; the
replaced Phase A cap was 50. Exclusions stay as in Phase A:
- titles the user already voted on in Greatest
- titles dismissed or hidden in Discover
- owned titles, matched by ID or by normalized title + year
- queued titles
The mature filter now uses isAdult (title-level, or an adult tag with
rank ≥ 60). The Phase A tag regex is kept only for candidates with no isAdult
data.
New score parts, all bounded:
| Part | Value | Rule |
|---|---|---|
communityRec |
0–5 | min(5, 1 + log2(1 + rating) / 2) × anchorWeight / 5; strongest anchor counts |
sameCreator |
0–4 | 4 × anchorWeight / 5 for a shared story/art author with an anchor weighted ≥ 3 |
tagTop |
0–3 | 3 for the user's top tag, 2 second, 1 third, only if averageScore ≥ 75 |
fresh |
0–2 | 2 if releasing and started within 2 years, 1 if only trending |
Existing parts (editorial, relation, sharedTaste, era, reading,
interest) and their weights are unchanged.
Slot mix for the 20 feed slots, applied greedily by descending score:
- At most 8 items whose strongest part comes from the same seed kind.
- Up to 4 slots reserved for
freshcandidates when such candidates exist. - About 20% (4 slots) taken from candidates outside the user's strongest tag cluster, as in the vision doc.
- If a rule cannot be met, the slot falls back to the next best candidate; the feed is never padded.
Reasons. A candidate found by several seeds collects every reason and evidence item. The tile shows the reason of the strongest part; the detail panel lists all of them. Reason templates:
| Kind | Template |
|---|---|
recs |
"AniList readers who liked {anchor} recommend this ({rating})." |
creator |
"Also by {author} — you {read/liked} {anchor}." |
tagTop |
"Top-rated in {tag}, one of your most-liked tags." |
fresh |
"New: started {year}, ongoing and trending." |
Evidence entries use kind: recommendation|creator|tag-top|fresh, link to the
AniList record, and carry fetchedAt.
5. API and UI
Routes:
POST /api/discovery/refresh— caller only, requires opt-in. Returns{ harvestId, position }. Returns429withnextAllowedAtwithin 1 hour of the caller's last refresh, and409if one is already queued for the caller.GET /api/discovery— addsrefresh: { state: idle|queued|running, position?, nextAllowedAt? }andfetchedAton evidence.POST /api/admin/discovery/harvest— admin only; starts a full harvest. Returns409while one runs.GET /api/admin/discovery— adds the last harvest record, cache file count and size, and the next nightly time.POST /api/admin/discovery/settings(new) —{ harvestEnabled: boolean, harvestAt: "HH:MM" }. Defaults:trueand03:30, overridable byDISCOVERY_HARVEST_AT. The value is validated; nothing else is accepted.
UI on discover.html:
- Refresh my suggestions button with three states: idle, queued/fetching (with queue position), and cooldown ("next refresh possible at 14:20").
- Reason labels per section 4; evidence shows "fetched {date}".
- The settings text for Personalized discovery says that the server sends AniList the IDs of titles you liked, without login, to find related work.
- Admin panel: last harvest (time, requests / budget, stop reason), cache size, next nightly run, Harvest now, enable switch and time.
6. Failure handling
| Condition | Behaviour |
|---|---|
| HTTP 429, 403, or a challenge page | Stop the harvest at once with stopReason, shown in the admin panel. No retry until the next nightly slot or admin action; never change identity, user agent or host. |
| Timeout or 5xx | Skip that request and count it. Five in a row abort the run (stopReason: upstream-errors). A timeout never counts as an empty result. |
| Invalid response | Dropped, counted as a failed request, not cached. |
| Restart during a harvest | On startup the run is marked interrupted. If the nightly slot was missed, one harvest runs after startup; there is never a catch-up storm. A queued per-user refresh is dropped and the user can press the button again. |
Corrupt discovery.json |
Unchanged from Phase A: reads and runs fail closed; the file is never overwritten. |
| No network | Harvest fails with a stop reason; scoring uses whatever is cached. A fresh install behaves exactly like Phase A. |
| Account deletion | Unchanged (feedback removed). Cache entries are not user-specific and stay. |
7. Performance
- Harvesting is I/O-bound and single-flight. At 25 requests per minute, a full nightly harvest takes about 12 minutes.
- Scoring 2,000 candidates for each user must keep the reader event loop
responsive. Each user is scored in its own
setImmediateturn, and a test asserts that one user's scoring stays below 200 ms on the fixture set. - If production shows reader stalls, moving the job to a worker container is recorded as a backlog item; it is not part of this change.
8. Testing
All network tests use recorded, anonymized AniList fixtures; no test calls the live API.
Unit:
- query builders and response normalization
- schema rejection of malformed responses
- dropping non-manga formats
- the rate limiter, with a fake clock
- stop on 429/403/challenge, and the consecutive-error abort
- cache TTL, size pruning, and corrupt-entry handling
- anchor weights, cap and dedup; reading activity used only with opt-in
- top-tag selection
- each new score part
- slot-mix limits and fallback
- merging reasons from several seeds
isAdultfilter- exclusion of owned, queued, voted and dismissed titles
Integration:
- per-user refresh: queue position,
429cooldown,409duplicate - admin harvest single-flight
- re-scoring after a per-user refresh leaves other users' feeds unchanged
- interrupted-run recovery
- one missed-night run after startup
- no harvest or score ever changes the scraper queue or library
- settings validation
Browser (existing disposable Compose suite):
- refresh button states
- reason labels for each seed kind
- evidence fetch date
- admin harvest panel
- desktop and mobile layout
9. Documentation
docs/discovery.md: seed kinds, budgets, cache, nightly time, privacy note, stop reasons.docs/deployment.md:data/discovery-cache/(safe to delete, rebuilt by the next harvest; may be left out of backups) andDISCOVERY_HARVEST_AT.docs/architecture.md: the new routes.vision/discovery-scout.md: mark the candidate-input part of section 4 as implemented.