Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ee72ff6
Show chapter names, and say which chapter Continue opens
Squeaks72 Sep 24, 2026
1eb1a87
Heal missing chapter names on every source check
Squeaks72 Sep 24, 2026
40d8986
Name the chapters the nightly sweep lands, too
Squeaks72 Sep 24, 2026
854577c
Keep a stored chapter name when a rescan finds the same file
Squeaks72 Sep 24, 2026
36d29b9
Translate the Continue line; test and document chapter names
Squeaks72 Sep 24, 2026
b4b9b04
Make the 18+ guard configurable: genres, sources, and per-series exem…
Squeaks72 Sep 24, 2026
b47fe5f
Settings UI for the 18+ filter
Squeaks72 Sep 24, 2026
4d16c14
reader: single arrow keypress turns a page in paged mode
Squeaks72 Sep 22, 2026
95f012f
feat(web): type-to-search opens the command palette pre-filled
Squeaks72 Sep 23, 2026
14425bf
Offer the 18+ reveal wherever the 18+ filter has something to hide
Squeaks72 Sep 24, 2026
d203438
Keep Discover's source filter through a search
Squeaks72 Sep 22, 2026
deec264
feat(reader): paged mode reads right to left when the series does
Squeaks72 Sep 24, 2026
dfa8c84
Reader settings that can be pinned to a source
Squeaks72 Sep 22, 2026
888a26b
fix: chapter list past chapter 1000; page it 100 at a time, opening a…
Squeaks72 Sep 24, 2026
790acea
Review fixes: pages named by chapter, a list that stays put, one row …
AngeloSha Sep 25, 2026
5ea797a
Review fixes: resume lands where you left off, and the bar and captio…
AngeloSha Sep 25, 2026
f594d68
Merge branch 'fix/pr90' into fix/pr92
AngeloSha Sep 25, 2026
62b244a
Review fixes: one keypress is one page, from the page on screen, and …
AngeloSha Sep 25, 2026
738fe29
Merge branch 'fix/pr92' into fix/pr95
AngeloSha Sep 25, 2026
c575523
Review fixes: a source default that holds for downloads, and never le…
AngeloSha Sep 25, 2026
17b15e7
Review fixes: type-to-search that respects an input method, can be sw…
AngeloSha Sep 25, 2026
cfe7c23
Review fixes: the genre list stays in the database, and the filter re…
AngeloSha Sep 25, 2026
41cdf3f
Review fixes: a chapter's name gets its own column, so a filename is …
AngeloSha Sep 25, 2026
12d3ff8
Schema for the configurable 18+ filter goes in its own v0.46.0 block
AngeloSha Sep 25, 2026
dcec536
Reader: no blank slide for a repeated page in paged mode, and the set…
AngeloSha Sep 25, 2026
da54b0a
Locale files count the string this change added
AngeloSha Sep 25, 2026
eca7344
The token form test follows the checkbox's new label
AngeloSha Sep 25, 2026
e456079
The docs follow the 18+ checkboxes' new label
AngeloSha Sep 25, 2026
73f9bfc
Merge #87 (pr/discover-source-filter-search) ahead of this branch
AngeloSha Sep 25, 2026
7807de2
Merge #89 (pr/chapter-list-paging) ahead of this branch
AngeloSha Sep 25, 2026
d5f1211
Merge #90 (pr/paged-rtl) ahead of this branch
AngeloSha Sep 25, 2026
89f5421
Merge #92 (pr/reader-arrow-key-paged) ahead of this branch
AngeloSha Sep 25, 2026
6001949
Merge #95 (pr/per-source-reader-prefs) ahead of this branch
AngeloSha Sep 25, 2026
067b179
Merge #94 (pr/type-to-search) ahead of this branch
AngeloSha Sep 25, 2026
0d6da5d
Merge #86 (pr/configurable-adult-filter) ahead of this branch
AngeloSha Sep 25, 2026
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
85 changes: 83 additions & 2 deletions bff/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,32 @@ paths:
description: OK
"401":
description: Not authenticated
/api/adult-filter:
get:
tags:
- library
summary: Whether the 18+ filter names any genre or source, so a client can offer the reveal without an 18+ library.
operationId: getApiAdultFilter
description: >-
`configured` is true when Admin → Settings → 18+ filter lists at least one genre or source
(adult_genres / adult_sources), i.e. when "Show 18+" would reveal something even on an install with
no library rated 18+. Only the flag is returned, never the lists. Always false for an account whose
age limit is below 18, which is never offered the reveal. Does not depend on `adult`.
security:
- bearer: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
required: [configured]
properties:
configured:
type: boolean
"401":
description: Not authenticated
/api/genres:
get:
tags:
Expand Down Expand Up @@ -1988,6 +2014,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 Expand Up @@ -4608,6 +4644,16 @@ paths:
Whether the nightly library repair runs on its own. On by default. Off stops the
schedule only: Run now and the Health page's chips still work, because nothing the
repair does is destructive (it never deletes, merges or renumbers anything).
adult_genres:
type: array
items:
type: string
description: The 18+ filter's genres, lowercased. See adultGenres on PATCH.
adult_sources:
type: array
items:
type: string
description: The source ids the 18+ filter treats as adult. See adultSources on PATCH.
"401":
description: Not authenticated
"403":
Expand All @@ -4617,7 +4663,8 @@ paths:
- admin
summary: "Change server settings: name, open registration, update interval, backup hour, extension check
interval and its kill switch, the update check, the opt-in install count, the opt-in read-chapter
cleanup, the bounded failed-chapter source hunt, and the server-wide scanlation-group preferences."
cleanup, the bounded failed-chapter source hunt, the server-wide scanlation-group preferences, and what
the 18+ switch hides besides 18+ libraries."
operationId: patchApiAdminSettings
description: >-
Requires an admin account and a token carrying the admin scope. Scopes only ever restrict. Answers
Expand Down Expand Up @@ -4696,6 +4743,33 @@ paths:
booksUnreadCount, so a fully read series still reports COMPLETED -- and they never
affect lastReadContinuousNumberSort. Nothing outside /api/v1 and /api/v2 changes.
type: boolean
adultGenres:
description: >-
Genres the "Show 18+" switch hides besides 18+ libraries, replaced whole. While a request
does not carry adult=1, every listing leaves out a series whose genres (an admin genre
override wins over the scanned list) include one of these, unless the series is exempted
(adultExempt on PUT /api/admin/series/{id}/meta). Surfacing only: by-id routes, links,
downloads and progress are unaffected, and the permission is still the member's age
limit. Stored lowercased and trimmed; a name with a character outside letters, digits,
space and ' - + / & ( ) . ! : is dropped, not stored. Empty by default.
type: array
maxItems: 60
items:
type: string
minLength: 1
maxLength: 60
adultSources:
description: >-
Source ids to treat as adult on top of the ones whose extension declares itself NSFW,
replaced whole. While a request does not carry adult=1 they drop out of the routes that
list sources or fan out over them (GET /api/sources, search-all, latest, popular). By-id
routes are unaffected. Same normalisation as adultGenres. Empty by default.
type: array
maxItems: 200
items:
type: string
minLength: 1
maxLength: 120
security:
- bearer: []
responses:
Expand Down Expand Up @@ -5498,7 +5572,7 @@ paths:
put:
tags:
- admin
summary: Override a series' metadata (title, summary, author, genres, status, age rating); survives rescans.
summary: Override a series' metadata (title, summary, author, genres, status, age rating, 18+ filter exemption); survives rescans.
operationId: putApiAdminSeriesIdMeta
description: Requires an admin account and a token carrying the admin scope. Scopes only ever restrict.
parameters:
Expand Down Expand Up @@ -5528,6 +5602,13 @@ paths:
type: string
ageRating:
type: integer
adultExempt:
type: [boolean, "null"]
description: >-
Keep this series in listings while "Show 18+" is off even when one of its genres is on
the 18+ filter (adultGenres on PATCH /api/admin/settings). Absent or null leaves the
current value alone; every other field here is written as sent. Does not affect 18+
libraries or the age limit.
security:
- bearer: []
responses:
Expand Down
48 changes: 42 additions & 6 deletions bff/src/lib/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,35 @@ export async function setBookDates(folder: string, chapters: { number: number; p
);
}

// A volume marker in front of the chapter: "Vol.3", "Volume 3 -", "Tome 3,".
const VOLUME = String.raw`(?:(?:vol(?:ume)?|tome|band|том)\.?\s*\d+(?:\.\d+)?\s*[,:.\-–—]?\s*)?`;
// The word a source puts before the number, in the languages sources are written in.
const CHAPTER_WORD = String.raw`(?:(?:ch(?:apter|ap)?|episode|ep|capítulo|capitulo|cap|chapitre|kapitel|глава|розділ|chương|bölüm|bab|rozdział)\.?\s*)?`;
// After the number, the separators between it and a real name.
const SEPARATOR = String.raw`\s*(?:[:.\-–—|~]+\s*)?`;

/**
* The chapter's own name, when the source gave one: what is left once the volume, the chapter word and the
* number are taken off the front. Null when nothing is left -- the title only said the number again.
*
* A downloaded file is named from its number alone (lib/downloader.ts explains why), so the scanner's title
* is the number twice. Sources usually know better, and the downloader writes it into the CBZ's ComicInfo,
* but nothing read it back. Most sources also just say "Chapter 12", in several languages and often behind a
* volume ("Vol.3 Chapter 12", "Capítulo 12", "第12話"): each of those is the number again, and "Vol.3 Chapter
* 12: The Return" is named "The Return".
*/
export function chapterName(title: string | undefined | null, number: number): string | null {
const t = (title ?? '').trim();
if (!t) return null;
const n = String(number).replace('.', '\\.');
// `(?!\.?\d)`: 12 must not match the front of 120 or 12.5.
const re = new RegExp(`^${VOLUME}${CHAPTER_WORD}(?:第\\s*)?0*${n}(?!\\.?\\d)(?:\\s*(?:話|话|章|回|화|편))?${SEPARATOR}`, 'iu');
const m = re.exec(t);
if (!m) return t;
const rest = t.slice(m[0].length).trim();
return rest || null;
}

/**
* Stamp which group released the file on disk, and which adapter it came from, onto a series' books.
*
Expand All @@ -660,21 +689,28 @@ export async function setBookDates(folder: string, chapters: { number: number; p
* `missing` is the 1-based list of placeholder pages when the chapter was saved partial (lib/partial.ts),
* and its absence writes NULL: a complete copy landing over a partial one -- a refetch, the completion
* pass falling through to another source -- clears the mark in the same stamp that records who wrote it.
*
* `title` is what the source called the chapter. Its name (chapterName, above) goes to `chapter_name` -- never
* to `title`, which is the filename's -- and only when there is one, so a copy whose source says only "Chapter
* 12" never replaces a name an earlier copy supplied.
*/
export async function setBookMeta(folder: string, landed: Array<{ number: number; scanlator?: string; source?: string; missing?: number[] }>): Promise<void> {
export async function setBookMeta(folder: string, landed: Array<{ number: number; scanlator?: string; source?: string; missing?: number[]; title?: string }>): Promise<void> {
const rows = landed.filter((c) => Number.isFinite(c.number));
if (!rows.length) return;
const values: string[] = [];
const params: any[] = [folder];
for (const c of rows) {
params.push(c.number, c.scanlator ?? null, c.source ?? null, c.missing?.length ? c.missing : null);
values.push(`($${params.length - 3}::real, $${params.length - 2}::text, $${params.length - 1}::text, $${params.length}::int[])`);
params.push(c.number, c.scanlator ?? null, c.source ?? null, c.missing?.length ? c.missing : null, chapterName(c.title, c.number));
values.push(`($${params.length - 4}::real, $${params.length - 3}::text, $${params.length - 2}::text, $${params.length - 1}::int[], $${params.length}::text)`);
}
await q(
`UPDATE lib_books b SET scanlator = v.grp, source_id = v.src, missing_pages = v.miss
FROM (VALUES ${values.join(',')}) AS v(n, grp, src, miss), lib_series s
`UPDATE lib_books b SET scanlator = v.grp, source_id = v.src, missing_pages = v.miss,
chapter_name = COALESCE(v.name, b.chapter_name)
FROM (VALUES ${values.join(',')}) AS v(n, grp, src, miss, name), lib_series s
WHERE s.folder = $1 AND b.series_id = s.id AND b.number = v.n
AND (b.scanlator IS DISTINCT FROM v.grp OR b.source_id IS DISTINCT FROM v.src OR b.missing_pages IS DISTINCT FROM v.miss)`,
AND (b.scanlator IS DISTINCT FROM v.grp OR b.source_id IS DISTINCT FROM v.src
OR b.missing_pages IS DISTINCT FROM v.miss
OR (v.name IS NOT NULL AND b.chapter_name IS DISTINCT FROM v.name))`,
params,
);
}
Expand Down
19 changes: 19 additions & 0 deletions bff/src/lib/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,25 @@ END $$;
-- Deliberately NOT constrained: reading_events is an append-only record of things that actually happened and
-- feeds stats, streaks, Wrapped and the leaderboard -- cascading it would rewrite someone's history because a
-- file moved. offline_downloads describes bytes on a user's phone, which the server cannot reconcile anyway.

-- v0.46.0: what the 18+ switch hides, beyond libraries rated 18+.
--
-- Library ratings alone could not express "keep ecchi off the shelf": that meant moving series into an
-- 18+ library, which is a filing decision made to get a display outcome, and it fought the scanner every
-- time a folder was rescanned. These two lists say it directly -- genres to treat as adult, and sources
-- to treat as adult on top of whatever their extension declares -- and they change nothing about where a
-- series is filed or who may open it. Empty by default, so an existing install behaves exactly as before.
ALTER TABLE server_settings ADD COLUMN IF NOT EXISTS adult_genres jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE server_settings ADD COLUMN IF NOT EXISTS adult_sources jsonb NOT NULL DEFAULT '[]'::jsonb;
-- "Hide everything tagged Mature, except this one." Without it the genre list is all-or-nothing, and one
-- wrongly-tagged classic is enough to make somebody turn the whole filter off.
ALTER TABLE series_overrides ADD COLUMN IF NOT EXISTS adult_exempt boolean;

-- v0.46.0: a chapter's own name, as its source gave it (lib/library.ts chapterName). Its OWN column, never
-- lib_books.title: title is the filename's -- "One Piece v02 c012 [Digital]" on a library built by hand -- and
-- writing names into it showed filenames as names and dropped the number from everything printing it alone.
-- (No backticks in this string: it is a template literal.)
ALTER TABLE lib_books ADD COLUMN IF NOT EXISTS chapter_name text;
`;

// Serialises migrate() across processes. CREATE TABLE IF NOT EXISTS is not safe to run concurrently:
Expand Down
5 changes: 4 additions & 1 deletion bff/src/lib/ownedCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const browseSrc = (ctx: ViewCtx, p: Params, alias = 'sv') => seriesSrcWith(brows
*/
const booksSrc = (ctx: ViewCtx, p: Params, alias = 'bv') => `(
SELECT b.id, b.series_id, b.source, b.file, b.root, b.pages, b.mtime, b.published_at, b.page_dims,
b.updated_at, b.fingerprint, b.scanlator, b.source_id, b.pruned_at, b.size, b.missing_pages,
b.updated_at, b.fingerprint, b.scanlator, b.source_id, b.pruned_at, b.size, b.missing_pages, b.chapter_name,
COALESCE(ov.number, b.number) AS number,
COALESCE(ov.title, b.title) AS title
FROM lib_books b
Expand Down Expand Up @@ -150,6 +150,9 @@ function bookDto(r: any) {
number: num,
media: { pagesCount: r.pages ?? 0, mediaType: 'application/vnd.comicbook+zip', status: 'READY' },
metadata: { title: r.title, number: String(num), numberSort: num, summary: '', releaseDate: released },
// The chapter's own name as its source gave it (lib/library.ts chapterName), null when it had none. NOT
// `name`/`metadata.title`, which are the filename's and keep saying so for every client that prints them.
chapterName: r.chapter_name ?? null,
// Who released the file on disk and which adapter it came from (setBookMeta in lib/library.ts). Both
// null for a book the scanner found rather than the downloader wrote. These are read through booksSrc's
// explicit column list above: a name dropped there does not error, it silently reads as null here.
Expand Down
4 changes: 2 additions & 2 deletions bff/src/lib/repair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ async function stepShort(r: RepairResult, opts: RepairOpts, budget: { left: numb
// Somebody else is already downloading into this folder (a series-page fetch, a Fetch newest run).
// Two writers on one path is a lost file and a rate-limit strike each; this one simply waits a night.
if (busyFolders.has(folder)) continue;
const allowed = sweepAllowedFor(await seriesIsAdult(seriesId).catch(() => false));
const allowed = await sweepAllowedFor(await seriesIsAdult(seriesId).catch(() => false));
// The sources this series is actually followed on -- the primary pair plus series_sources, exactly as
// listingAlternates builds it (lib/updater.ts). A listing row's source is trusted only while the
// series still follows it: a copy left behind by a source somebody unfollowed is not ours to ask.
Expand Down Expand Up @@ -759,7 +759,7 @@ async function stepGaps(r: RepairResult, opts: RepairOpts, budget: { left: numbe
};

if (unlisted.size) {
const allowed = sweepAllowedFor(await seriesIsAdult(s.id).catch(() => false));
const allowed = await sweepAllowedFor(await seriesIsAdult(s.id).catch(() => false));
const found = await huntCandidates(s.id, {
allowed, budget, reason: 'gap', force: !!opts.seriesId,
// The candidate must be able to fill a hole nobody else lists. `assess` over the RAW list it
Expand Down
31 changes: 31 additions & 0 deletions bff/src/lib/seriesListing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { q, one, tx } from './db';
import { getSource, type SourceChapter } from './sources';
import { groupsOf, normGroup } from './releases';
import { CHAPTER_RETRY_CAP } from './updater';
import { chapterName } from './library';
import { effectivePrefsFor, readSeriesPrefs } from './scanlatorPrefs';

export type ListingStatus = 'available' | 'held' | 'blocked';
Expand Down Expand Up @@ -156,6 +157,36 @@ export async function replaceListing(seriesId: string, rows: ListingRow[]): Prom
params,
);
}

/**
* Give a chapter its own name, when the listing knows one and the book has none yet.
*
* Every chapter fetched before the downloader recorded the source's name has none, and the listing being
* written right here is the same data, for the same chapters, already in hand -- so the repair costs a
* statement per check rather than a migration that can only run once. The name is worked out by the same
* rule the downloader's stamp uses (library.ts chapterName, which knows "Vol.3 Chapter 12" and "第12話"
* for the number again), in JavaScript, and written to `chapter_name` only -- never to `title`, the
* filename's. A name already there is kept: the copy on disk named it.
*/
const named = new Map<number, string>();
for (const r of rows) {
const name = Number.isFinite(r.number) ? chapterName(r.title, r.number) : null;
if (name && !named.has(r.number)) named.set(r.number, name);
}
const all = [...named];
for (let i = 0; i < all.length; i += 1000) { // well inside Postgres's 65 535 parameters
const params: unknown[] = [seriesId];
const values = all.slice(i, i + 1000).map(([n, name]) => {
params.push(n, name);
return `($${params.length - 1}::real, $${params.length}::text)`;
});
await qq(
`UPDATE lib_books b SET chapter_name = v.name, updated_at = now()
FROM (VALUES ${values.join(',')}) AS v(n, name)
WHERE b.series_id = $1 AND b.number = v.n AND b.chapter_name IS NULL`,
params,
);
}
});
}

Expand Down
14 changes: 9 additions & 5 deletions bff/src/lib/sourceHunt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { chooseReleases, type ReleasePrefs } from './releases';
import { effectivePrefsFor, readSeriesPrefs } from './scanlatorPrefs';
import { MIN_HAVE } from './fill';
import { logAudit } from './audit';
import { ADULT_RATING } from './visibility';
import { ADULT_RATING, adultFilter } from './visibility';

/** How long after one hunt a series may be hunted for again, whatever became of the first. */
export const HUNT_COOLDOWN_MS = 24 * 3600_000;
Expand Down Expand Up @@ -102,11 +102,15 @@ export async function seriesIsAdult(seriesId: string): Promise<boolean> {

/**
* The sweep's rule for which sources it may reach on a series' behalf: any source for an adult series,
* and only sources that do not declare themselves adult for every other. A viewer-driven path passes the
* viewer's own cap (visibility.sourceAllowedFor) instead; this is for the paths that have no viewer.
* and for every other only sources that are not adult -- neither declared so by their extension nor named so
* by the admin (Admin -> Settings -> 18+ filter). The admin's list used to be ignored here, so a source named
* adult could still be followed onto a clean series by the failure hunt or the nightly repair. A viewer-driven
* path passes the viewer's own cap (visibility.sourceAllowedFor) instead; this is for paths with no viewer.
*/
export function sweepAllowedFor(adult: boolean): (sourceId: string) => boolean {
return (id) => !getSource(id)?.isNsfw || adult;
export async function sweepAllowedFor(adult: boolean): Promise<(sourceId: string) => boolean> {
if (adult) return () => true;
const named = new Set((await adultFilter().catch(() => ({ sources: [] as string[] }))).sources);
return (id) => !getSource(id)?.isNsfw && !named.has(String(id).toLowerCase());
}

async function huntOn(): Promise<boolean> {
Expand Down
Loading
Loading