Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bff/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1988,6 +1988,16 @@ paths:
schema:
type: string
description: "`1` to include adult sources in the fan-out. Absent means they are not asked."
- name: source
in: query
required: false
schema:
type: string
description: >-
Ask only this source id (what Discover sends while it is filtered to one source). Narrows the
fan-out within the sources the caller may be shown, never past them: an id outside that set
(an adult source without `adult=1`, one above the account's age limit, an unknown id) asks
nobody and answers the empty shape. Absent means every source.
- name: groupBy
in: query
required: false
Expand Down
4 changes: 3 additions & 1 deletion bff/src/routes/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,8 +708,10 @@ export default async function catalogRoutes(app: FastifyInstance) {
const planShown = await browsableIds(favAll, vc(req));
const favIds = favAll.filter((id) => planShown.has(id));
const out: { bookId: string; seriesId: string }[] = [];
// Every chapter, not the first 1000: past chapter 1000 of One Piece the unread ones were all beyond
// the page and the plan came back empty.
for (const sid of favIds) {
const raw = await komga.seriesBooks(vc(req), sid, 0, 1000, 'metadata.numberSort,asc').catch(() => null);
const raw = await komga.seriesBooks(vc(req), sid, 0, 100000, 'metadata.numberSort,asc').catch(() => null);
if (!raw) continue;
const books = await booksForUser(req, raw.content);
// A pruned chapter has no pages to download; planning it would queue a manifest that answers 410.
Expand Down
16 changes: 14 additions & 2 deletions bff/src/routes/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1781,7 +1781,7 @@ export default async function sourceRoutes(app: FastifyInstance) {
* polls the same URL with a short `wait` until it is 0.
*/
app.get('/api/sources/search-all', async (req) => {
const { q: rawQ, groupBy, wait } = req.query as { q?: string; groupBy?: string; wait?: string };
const { q: rawQ, groupBy, wait, source } = req.query as { q?: string; groupBy?: string; wait?: string; source?: string };
const term = (rawQ || '').trim();
if (!term) return { content: [], sources: [], pending: 0, asked: 0 };
// Absent means the full first-answer wait, so a caller written before `wait` existed gets the most
Expand All @@ -1795,7 +1795,19 @@ export default async function sourceRoutes(app: FastifyInstance) {
// "Show 18+" chip. The chip belongs here and not only in the shaping below, because a source left in
// `ask` is a source this request STARTS -- an outbound query to an adult site on behalf of someone who
// asked not to see one, and its results would then also land in the shared entry under this term.
const ask = surfaceable(req);
const all = surfaceable(req);
// `source` narrows the fan-out to one source, for a search made while Discover is filtered to it. The
// filter was display-only before, and did not survive a search at all: submitting a term asked every
// source and answered with everything, so choosing a source and then searching within it was not
// possible. Narrowing here rather than filtering the answer also makes it one outbound request instead
// of a dozen, which is the difference between an instant answer and the slowest source's timeout.
//
// It only ever narrows `surfaceable`, never widens it: an id outside that set -- an adult source with
// the reveal off, one an age cap puts out of reach, or one that does not exist -- leaves `ask` empty,
// nobody is asked, and the answer is the ordinary nothing-found shape. The entry is still keyed by the
// term alone, so a narrowed search and a full one share whatever the sources have already answered.
const only = typeof source === 'string' ? source : '';
const ask = only ? all.filter((s) => s.id === only) : all;
const health = new Map((await healthAll().catch(() => [])).map((h) => [h.source_id, h] as const));
const ans = await searchAll(term, ask, { waitMs, health });
const byId = new Map(ask.map((s) => [s.id, s] as const));
Expand Down
30 changes: 30 additions & 0 deletions bff/test/searchAll.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,36 @@ test('an empty term answers the same shape without asking anyone', { skip }, asy
assert.deepEqual(r.json(), { content: [], sources: [], pending: 0, asked: 0 });
});

test('source= narrows the fan-out to that one source, and cannot reach past the caller\'s own set', { skip }, async () => {
// Discover filtered to one source keeps that filter through a search. Narrowing is done on the ASK, not on
// the answer, so the other sources are not even asked: one outbound request rather than a dozen, and the
// answer comes as soon as that source does. Reintroduce by ignoring `source` in the route: the other
// sources' counters move and `sources` lists all of them.
const before = { ...calls };
const r = await app.inject({ method: 'GET', url: `/api/sources/search-all?q=Narrowed%20Term&source=${FAST}`, headers: tok(ids.plain) });
assert.equal(r.statusCode, 200);
const j = r.json();
assert.deepEqual(j.sources.map((x: any) => x.id), [FAST], 'the answer covers more than the chosen source');
assert.equal(j.asked, 1);
assert.ok(j.content.length > 0 && j.content.every((g: any) => g.providers.every((p: any) => p.source === FAST)),
'a card came from a source that was not chosen');
assert.equal(calls[FAST] - before[FAST], 1, 'PREMISE: the chosen source was not asked');
const others = ALL.filter((id) => id !== FAST && calls[id] !== before[id]);
assert.deepEqual(others, [], `sources outside the filter were asked: ${others.join(', ')}`);

// The filter only ever narrows `surfaceable`: naming an adult source without the reveal, or naming one an
// age cap puts out of reach even with it, asks nobody and answers the ordinary empty shape.
const adultBefore = calls[ADULT];
for (const [url, who] of [
[`/api/sources/search-all?q=Narrowed%20Adult&source=${ADULT}`, ids.plain],
[`/api/sources/search-all?q=Narrowed%20Adult&source=${ADULT}&adult=1`, ids.capped],
] as const) {
const a = (await app.inject({ method: 'GET', url, headers: tok(who) })).json();
assert.deepEqual([a.content, a.sources, a.asked], [[], [], 0], `${url} reached a source outside the caller's set`);
}
assert.equal(calls[ADULT], adultBefore, 'naming the adult source asked it');
});

test('after the TTL the sources are asked again', { skip }, async () => {
// Reintroduce by dropping the TTL check at the top of searchAll: the entry answers forever and the
// counter does not move.
Expand Down
29 changes: 29 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ genres, description, and the **chapter list**.
- **Mark all read** does what it says to every chapter of the series; **Filter** narrows the list to one
translation group or hides the grey rows; **Select** picks chapters one by one for the actions described
in *Selecting chapters* below.
- A long series shows its chapters **100 at a time**, with a pager above and below the list (first,
previous, a picker named by the rows each page holds — *901–1000* — next, last). The list opens on the
page holding the chapter *Continue* would open, so a reader on chapter 956 lands among the 900s. Picking a
page keeps you there; a new series, sort order or filter goes back to following *Continue*. Every chapter
is listed — before this, the list and the reader's chapter list stopped at chapter 1000.
- The line under the title — *MangaDex · Example Scans +2 · 4 not here yet ›* — is the series' source, who
translates it and how many chapters the sources have that this server does not. Tap it for **Sources &
translations**, described below.
Expand Down Expand Up @@ -546,17 +551,38 @@ you keep scrolling through a series without interruption.
- **Per-series memory:** your zoom/theme choices are remembered per title.
- **Jump to a chapter:** the chapter button in the top bar opens the full list, at every screen size. On a
desktop `[` / `]` step to the previous/next chapter as well.
- **Keyboard:** in paged mode **→** / **↓** / **Page Down** / **Space** turn to the next page and **←** / **↑** /
**Page Up** to the previous one, one press per page, the same as tapping the edge of the page. In webtoon
scroll, **Space** / **↓** and **↑** scroll by most of a screen.
- **Jump to a page:** tap the page counter (`4/18`) in the bottom bar for a thumbnail grid of the chapter.
- **Desktop:** the page is centered with comfortable margins.

It remembers your scroll position, so closing and reopening drops you right back where you were.

**Right-to-left paged reading.** In paged mode, *Reading direction* (in the reader's settings sheet and under
Profile → Settings → Reading) can lay the pages out right to left, the way manga is printed: the next page is
to the left, so you swipe right, tap the left edge or press ←. A double spread puts its first page on the
right, and the bottom bar mirrors with it — the page slider fills from the right, and the next-chapter button
moves to the left. The default, *Series default*, does this only for a series whose metadata says it reads
right to left (a Komga library can say so; the built-in library does not yet, so there every series reads left
to right as before). *Left to right* and *Right to left* override it for every series. The webtoon scroll is
unaffected.

**Reader defaults** — mode (webtoon scroll or paged), theme, repeated pages, fit, page gap, auto-scroll and
brightness — live under **Profile → Settings → Reading**, where each one saves as you change it and says
*Saved* beside the row. The reader's own sheet still changes them for the session you are in, and a series
you have adjusted keeps its own memory, which wins over the defaults. The weekly goal, offline downloads and
new-chapter alerts are on the same tab.

**A default per source.** A source is usually one format: a webtoon site wants the continuous vertical scroll,
a manga site wants paged right-to-left. At the bottom of the reader's settings sheet, **Use this reader for
everything from *Source*** saves the current mode, theme and two-page spread for every title from the source
the chapter came from, so one choice fixes that whole part of the library. The order is *your defaults <
the source's default < this series*: a title you have adjusted by hand still keeps its own settings. **Forget
the default for *Source*** removes it again. Like the per-series memory it is saved to your account, so it
follows you to your other devices. A downloaded chapter opened offline carries no source, so the button does
not appear there.

### Skipping the pages that are not the story

Most scanlated chapters open with the same credit page, and some carry an advert or a "read the rest at…"
Expand Down Expand Up @@ -617,6 +643,9 @@ again.
about six seconds); source rows show *Searching…*, empty, failed, disabled or cooling-down states while
the rest arrive. Repeating the same search continues the in-flight work and a recent term opens from the
five-minute cache. Results are still filtered for the signed-in account, including its age limit.
With one source chosen in the chip, a search asks **only that source** and the chip stays on screen while
the results are up, so you can see the search is narrowed and clear it with its × (which searches every
source again). Switching the toggle to *Newest* or *Popular* goes back to browsing.
- **Add:** tap a card and pick which source to add it from — each with its favicon, the first marked *most
used* (skipped when only one has it). The dialog then opens with *From {source} · Change*. Choose
**Chapters to fetch now** (All, First N, Latest N, or **Nothing yet — pick chapters later**), toggle
Expand Down
5 changes: 5 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,11 @@ slot count) at a time, one lane for solver-fronted sources and one for the rest;
budget is recorded as slow (never as a failure), one that throws is recorded against its health, and one that
is disabled or in a cooldown is skipped rather than asked — exactly as the newest listing treats them.

`&source=<id>` asks only that one source, which is what Discover sends while it is filtered to a source. It
narrows the set described above and never widens it: an adult source without `adult=1`, one above your age
limit, or an id that does not exist asks nobody and answers the empty shape. The entry is still keyed by the
term alone, so a narrowed search reads whatever a full one already heard, and the other way round.

`GET /api/sources/detail` is cached for ten minutes per source and series (it was ninety seconds), and
concurrent requests for the same pair — the add dialog's pre-warm and the pick that follows it — collapse
into one outbound fetch. A failed lookup is never cached, so an immediate retry asks the source again.
Expand Down
52 changes: 33 additions & 19 deletions web/app/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,15 @@ export default function DiscoverPage() {
* same term again inside five minutes is instant, and leaving search mode stops the polling by itself.
*/
const searchQ = useQuery({
queryKey: ['search-all', term],
// `selected` is part of the key: narrowing to a source is a different question, and must not be
// answered from the unfiltered search's cache.
queryKey: ['search-all', term, selected],
queryFn: ({ signal, queryKey, client }) => {
// ⚠️ Only the first request may wait the long wait. A poll that also waited six seconds would hold
// its answer until the server's grace expired, so the wall would fill in six seconds late every time.
const first = (client.getQueryState(queryKey)?.dataUpdateCount ?? 0) === 0;
return api<SearchAnswer>(`/api/sources/search-all?q=${encodeURIComponent(term)}&wait=${first ? SEARCH_FIRST_WAIT_MS : SEARCH_POLL_WAIT_MS}`, { signal });
const only = selected ? `&source=${encodeURIComponent(selected)}` : '';
return api<SearchAnswer>(`/api/sources/search-all?q=${encodeURIComponent(term)}&wait=${first ? SEARCH_FIRST_WAIT_MS : SEARCH_POLL_WAIT_MS}${only}`, { signal });
},
enabled: mode === 'search' && !!term,
// A failed search is shown as one; retrying it would be another fan-out to every source.
Expand All @@ -227,11 +230,18 @@ export default function DiscoverPage() {
});
// The grouped hits as wall rows, under today's mapping: the first provider's ids are the card's, the badge
// counts every provider. Derived, so a poll's answer replaces the rows without anything being cleared.
const searchHits = useMemo<SourceItem[]>(() => (searchQ.data?.content ?? []).map((g) => ({
source: g.providers[0]?.source ?? '', sourceId: g.providers[0]?.sourceId ?? g.title,
title: g.title, coverUrl: g.coverUrl, updatedAt: g.updatedAt,
inLibrary: g.inLibrary, providerCount: g.providers.length,
})), [searchQ.data]);
const searchHits = useMemo<SourceItem[]>(() => (searchQ.data?.content ?? []).flatMap((g) => {
// With a source chosen the server has already asked only that one, so this is belt-and-braces: keep
// the card only if that source is among its providers, and let that provider be the card's own, so
// tapping it opens the source being browsed rather than whichever the fold happened to rank first.
const pick = selected ? g.providers.find((p) => p.source === selected) : g.providers[0];
if (!pick) return [];
return [{
source: pick.source ?? '', sourceId: pick.sourceId ?? g.title,
title: g.title, coverUrl: g.coverUrl, updatedAt: g.updatedAt,
inLibrary: g.inLibrary, providerCount: g.providers.length,
}];
}), [searchQ.data, selected]);
const groupsRef = useRef<Record<string, SearchGroup['providers']>>({});
// What each search stored, keyed the way the wall's own fold is, so open() offers the providers of a hit
// the same way it offers the providers of a folded card. Written from the answer, never from state.
Expand Down Expand Up @@ -449,18 +459,22 @@ export default function DiscoverPage() {
</div>
</header>

{mode === 'newest' && (
<SourcePicker
sources={budget} states={states} settled={settled} total={budget.length}
// The chip's number is the whole pool, not the budget and not the ranked list: the budget widens
// as sources answer empty, and a count that ticks upward on its own reads as a bug; the ranked
// list is capped at twelve, and "12 sources" on a 14-source install is simply false.
count={pool.length}
selected={selected} onSelect={setSelected}
mode={listMode}
onMode={(m) => { setListMode(m); setSelected(null); setPage(1); }}
/>
)}
{/*
In both modes, not just while browsing. The picker is the only place the chosen source is visible or
clearable, so hiding it during a search -- while the search itself is narrowed to that source --
would leave the results silently filtered with nothing on screen to say why. Choosing a list tab is
a browse action, so from a search it returns to browsing that list.
*/}
<SourcePicker
sources={budget} states={states} settled={settled} total={budget.length}
// The chip's number is the whole pool, not the budget and not the ranked list: the budget widens
// as sources answer empty, and a count that ticks upward on its own reads as a bug; the ranked
// list is capped at twelve, and "12 sources" on a 14-source install is simply false.
count={pool.length}
selected={selected} onSelect={setSelected}
mode={listMode}
onMode={(m) => { setListMode(m); setSelected(null); setPage(1); if (mode === 'search') backToNewest(); }}
/>

{/* One mounted child per budgeted source. Renders nothing; owns one request.
The key carries the listing mode, so switching Newest/Popular REMOUNTS these and they fetch the
Expand Down
Loading
Loading