Files
reader/web/src/api/client.ts
T

1009 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { abortedError, abortFailure, combineAbortSignals, isAbortError } from '../core/abort.ts';
import type { ChapterQuality, CredentialStatus, OpdsCredential } from './sources.ts';
import type { ReadingOverrides } from './types.ts';
import { eventStream } from './event-stream.ts';
import type { ExtensionField, ExtensionPage } from './sources.ts';
import { ApiError, errorForStatus, parseErrorBody } from './errors.ts';
import type {
BatchResult,
Book,
BookContent,
BrowseListing,
ConflictPolicy,
BookListPage,
ContinueReadingItem,
Facets,
ReadingState,
InstanceInfo,
Manifest,
Note,
NoteType,
Progress,
Session,
ShelfAction,
SyncPull,
SyncPushResult,
TocEntry,
UploadResult,
User,
} from './types.ts';
import type { Platform } from '../core/platform.ts';
import type { SourceType, SourceInstance, SourcePlugin, SourceEntry, SourcePage, SourceAcquisition, ChapterSubscription } from './sources.ts';
import { isCancelledError } from '@tanstack/query-core';
import { affectedNavigationQuery, createReaderQueryClient, isLiveNavigationQuery, isNavigationQuery, readerQueryKey } from './query.ts';
export interface SessionStore {
load(): Promise<Session | null>;
save(session: Session): Promise<void>;
clear(): Promise<void>;
}
/**
* What this instance says it can synthesise.
*
* `http: false` is the normal answer for a deployment with no `TTS_URL`, and it
* is deliberately a 200 rather than a 404: the client's question is "which
* engines may I offer here", not "does this endpoint exist".
*/
export interface TtsCapabilities {
timeoutMs?:number;
http: boolean;
formats: string[];
maxLength: number;
voices: Array<{ id: string; name: string; lang: string }>;
}
export interface ListQuery {
/** 在读 / 想读 / 已读; shelf scope only. */
status?: ReadingState;
search?: string;
author?: string;
series?: string;
tag?: string;
format?: string;
/**
* Sort key.
*
* `updated` is what the shelf has always used and stays the default: a library
* is browsed by "what did I just add", and a reader who wants A–Z can ask for
* `title`. The list is the server's own `ListOptions.sort`, so the two cannot
* drift.
*/
sort?: 'title' | 'author' | 'added' | 'updated';
order?: 'asc' | 'desc';
/**
* Restrict the list to books with a file inside this folder.
*
* Library-relative and recursive, `''` meaning the whole library. It is what the
* library screen's preview page asks with, and it is *not* a filter the shelf
* uses: the shelf is "my books" and a folder is a place inside the library, which
* are different questions (see `library-screen.tsx`).
*/
path?: string;
/**
* Which set to list: the reader's shelf (default), or the library's index.
*
* `scope: 'library'` answers "what is in this folder" — every indexed book, each
* carrying `shelfState` — instead of "what is on my shelf". The browsing page is
* the only caller: it is the page that has to show a book the reader has *not*
* shelved, in order to offer to shelve it.
*/
scope?: 'shelf' | 'library';
page?: number;
pageSize?: number;
}
export interface ProgressInput {
bookId: string;
locator: string;
percentage: number;
chapterTitle: string;
/** Device label; the server records it so the UI can say "read on Pixel". */
device: string;
updatedAt: number;
}
export interface NoteInput {
id: string;
bookId: string;
type: NoteType;
locator: string;
text: string;
comment: string;
color: string;
updatedAt: number;
deleted: boolean;
}
/** One entry per HTTP call, so callers can drive their own retry policy. */
export interface RequestOptions {
signal?: AbortSignal;
/** Skip the automatic refresh-and-retry, used by the refresh call itself. */
noRetry?: boolean;
}
/**
* The single place that knows how to talk to a reader server.
*
* Responsibilities kept here on purpose:
* - attaching the access token
* - refreshing it exactly once when it expires, for any in-flight call
* - de-duplicating concurrent refreshes, so a screen that fires six requests
* after waking up does not rotate the refresh token six times (rotation is
* single-use on the server — the extra five would be rejected and would
* sign the user out)
*/
export class ReaderApi {
private session: Session | null = null;
private refreshPromise: Promise<Session> | null = null;
private sessionGeneration = 0;
private readonly streams = new Set<AbortController>();
private readonly queryClient = createReaderQueryClient();
private queryScope = 0;
private sessionWrites: Promise<void> = Promise.resolve();
private readonly listeners = new Set<(session: Session | null) => void>();
constructor(
private readonly platform: Platform,
private readonly sessions: SessionStore,
) {}
get baseUrl(): string {
return this.currentBaseUrl;
}
private currentBaseUrl = '';
setBaseUrl(url: string): void {
const next = url.replace(/\/+$/, '');
if (next !== this.currentBaseUrl) {
for (const stream of this.streams) stream.abort();
this.sessionGeneration += 1;
this.refreshPromise = null;
this.queryScope += 1;
}
this.currentBaseUrl = next;
}
async restore(): Promise<Session | null> {
const stored = await this.sessions.load();
if (!stored) return null;
this.sessionGeneration += 1;
this.session = stored;
return stored;
}
currentSession(): Session | null {
return this.session;
}
/** Renew before expiry; transient failures leave the persisted login intact. */
async renewSessionIfNeeded(): Promise<void> {
if (!this.session || Math.min(this.session.accessTokenExpiresAt, this.session.resourceTokenExpiresAt ?? Infinity) > Date.now() + 5 * 60_000) return;
await this.refreshSession(this.sessionGeneration);
}
onSessionChange(listener: (session: Session | null) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private async setSession(session: Session | null, rotation = false): Promise<void> {
if (!rotation) {
for (const stream of this.streams) stream.abort();
this.sessionGeneration += 1;
this.refreshPromise = null;
}
const generation = this.sessionGeneration;
this.session = session;
if (!rotation) this.queryScope += 1;
const write = this.sessionWrites.catch(() => undefined).then(() =>
session ? this.sessions.save(session) : this.sessions.clear());
this.sessionWrites = write;
await write;
if (generation !== this.sessionGeneration) return;
for (const listener of this.listeners) listener(session);
}
async signOut(): Promise<void> {
const generation = this.sessionGeneration;
const refreshToken = this.session?.refreshToken;
if (refreshToken) {
// Best effort: a failed revocation must not trap the user in a session
// they asked to leave. The local credentials are cleared either way.
await this.call('/api/v1/auth/logout', 'POST', { refreshToken }).catch(() => undefined);
}
if (generation !== this.sessionGeneration) return;
await this.setSession(null);
}
// ---- auth ----
async instance(): Promise<InstanceInfo> {
return this.get<InstanceInfo>('/api/v1/instance');
}
async register(username: string, password: string, displayName?: string, inviteCode?: string): Promise<Session> {
const { session } = await this.call<{ user: User; session: Session }>('/api/v1/auth/register', 'POST', {
username,
password,
...(displayName ? { displayName } : {}),
...(inviteCode ? { inviteCode } : {}),
});
await this.setSession(session);
return session;
}
async login(username: string, password: string): Promise<Session> {
const session = await this.call<Session>('/api/v1/auth/login', 'POST', { username, password });
await this.setSession(session);
return session;
}
async me(): Promise<User> {
const generation=this.sessionGeneration;
const result = await this.get<{ user: User }>('/api/v1/auth/me');
if(generation===this.sessionGeneration&&this.session?.user.id===result.user.id){
const session={...this.session,user:result.user};
this.session=session;
const write=this.sessionWrites.catch(()=>undefined).then(()=>generation===this.sessionGeneration?this.sessions.save(session):undefined);
this.sessionWrites=write;
await write;
}
return result.user;
}
async adminUsers(): Promise<{users:import('./types.ts').ManagedUser[]}> { return this.get('/api/v1/admin/users'); }
async adminCreateUser(input:{username:string;password:string;displayName:string;role:'admin'|'member';mediaEnabled?:boolean}): Promise<unknown> { return this.call('/api/v1/admin/users','POST',input); }
async adminUpdateUser(id:string,input:{disabled?:boolean;role?:'admin'|'member';mediaEnabled?:boolean}): Promise<unknown> { return this.call('/api/v1/admin/users/'+encodeURIComponent(id),'PATCH',input); }
async adminResetPassword(id:string,password:string): Promise<unknown> { return this.call('/api/v1/admin/users/'+encodeURIComponent(id)+'/password','POST',{password}); }
async registrationSettings(): Promise<import('./types.ts').RegistrationSettings> { return this.get('/api/v1/admin/registration'); }
async setRegistrationMode(mode:import('./types.ts').RegistrationSettings['mode']): Promise<unknown> { return this.call('/api/v1/admin/registration','PATCH',{mode}); }
async createInvite(input:{label:string;maxUses:number;days:number}): Promise<{code:string}> { return this.call('/api/v1/admin/invites','POST',input); }
async disableInvite(id:string): Promise<unknown> { return this.call('/api/v1/admin/invites/'+encodeURIComponent(id),'DELETE'); }
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await this.call('/api/v1/auth/password', 'POST', { currentPassword, newPassword });
// The server revokes every session on a password change, including this one.
await this.setSession(null);
}
// ---- shelf ----
async sourcePage(id: string, pageId: string, action?: string, values?: Record<string, unknown>): Promise<ExtensionPage> {
const path = '/api/v1/sources/' + encodeURIComponent(id) + '/pages/' + encodeURIComponent(pageId);
return action ? this.call(path, 'POST', { action, values }) : this.get(path);
}
async pluginPage(id: string, pageId: string, action?: string, values?: Record<string, unknown>): Promise<ExtensionPage> {
const path = '/api/v1/plugins/' + encodeURIComponent(id) + '/pages/' + encodeURIComponent(pageId);
return action ? this.call(path, 'POST', { action, values }) : this.get(path);
}
async sourceFilters(id: string): Promise<ExtensionField[]> { return this.get('/api/v1/sources/' + encodeURIComponent(id) + '/search-filters'); }
async sourceOptions(id: string): Promise<{ canSwitch: boolean }> { return this.get('/api/v1/books/' + encodeURIComponent(id) + '/source-options'); }
alternatives(id: string, query: { sessionId: string; cursor?: string }, options: RequestOptions = {}): AsyncGenerator<SourcePage> {
return this.catalogStream('/api/v1/books/' + encodeURIComponent(id) + '/alternatives', query, options);
}
async refreshChapter(id: string, ref: string, options: RequestOptions = {}): Promise<Blob> {
const response = await this.request('/api/v1/books/' + encodeURIComponent(id) + '/refresh-chapter', 'POST', { ref }, { ...options, binary: true });
const bytes = new Uint8Array(response.bytes ?? []);
return new Blob([bytes.buffer]);
}
async switchQuality(id: string, entryRef: string, chapterId: string, options: RequestOptions = {}): Promise<ChapterQuality> {
return this.call('/api/v1/books/' + encodeURIComponent(id) + '/switch-quality','POST',{entryRef,chapterId},options);
}
async credentialStatus(id: string): Promise<CredentialStatus> { return this.get('/api/v1/sources/' + encodeURIComponent(id) + '/credentials'); }
async opdsCredentials(): Promise<{ credentials: OpdsCredential[]; catalogUrl: string }> { return this.get('/api/v1/opds/credentials'); }
async createOpdsCredential(name: string): Promise<OpdsCredential & { username: string; password: string; catalogUrl: string }> { return this.call('/api/v1/opds/credentials', 'POST', { name }); }
async revokeOpdsCredential(id: string): Promise<void> { await this.call('/api/v1/opds/credentials/' + encodeURIComponent(id), 'DELETE'); }
async deleteCredential(id: string, key: string): Promise<void> { await this.call('/api/v1/sources/' + encodeURIComponent(id) + '/credentials/' + encodeURIComponent(key),'DELETE'); }
async switchPreview(id: string, entryRef: string): Promise<{ chapters: Array<{ id: string; title: string }>; latestChapter?: string }> {
return this.call('/api/v1/books/' + encodeURIComponent(id) + '/switch-preview', 'POST', { entryRef });
}
async switchSource(id: string, entryRef: string, chapterId: string, revision: string): Promise<{ content: BookContent; href: string }> {
return this.call('/api/v1/books/' + encodeURIComponent(id) + '/switch-source', 'POST', { entryRef, chapterId, revision });
}
async sourceTypes(): Promise<SourceType[]> { return (await this.get<{ types: SourceType[] }>('/api/v1/sources/types')).types; }
async sources(): Promise<SourceInstance[]> { return (await this.get<{ sources: SourceInstance[] }>('/api/v1/sources')).sources; }
async plugins(): Promise<SourcePlugin[]> { return (await this.get<{ plugins: SourcePlugin[] }>('/api/v1/plugins')).plugins; }
async saveSource(id: string | null, input: Record<string, unknown>): Promise<SourceInstance> {
return (await this.call<{ source: SourceInstance }>(`/api/v1/sources${id ? `/${encodeURIComponent(id)}` : ''}`, id ? 'PATCH' : 'POST', input)).source;
}
async removeSource(id: string): Promise<void> { await this.call(`/api/v1/sources/${encodeURIComponent(id)}`, 'DELETE'); }
async sourceCredential(id: string, key: string, value: string): Promise<void> {
await this.call(`/api/v1/sources/${encodeURIComponent(id)}/credentials/${encodeURIComponent(key)}`, 'PUT', { value });
}
async installPlugin(packageName: string): Promise<SourcePlugin> { return (await this.call<{ plugin: SourcePlugin }>('/api/v1/plugins', 'POST', { package: packageName, trusted: true })).plugin; }
async uploadPlugin(file: File): Promise<SourcePlugin> {
const form = new FormData();
form.append('trusted', 'true');
form.append('file', file, file.name);
return (await this.call<{ plugin: SourcePlugin }>('/api/v1/plugins/upload', 'POST', form)).plugin;
}
async enablePlugin(id: string, enabled: boolean): Promise<void> { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'PATCH', { enabled }); }
async uninstallPlugin(id: string): Promise<void> { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'DELETE'); }
async sourceCatalog(id: string, query: { ref?: string; cursor?: string } = {}, options: RequestOptions = {}): Promise<SourcePage> {
const params = new URLSearchParams();
if (query.ref) params.set('ref', query.ref);
if (query.cursor) params.set('cursor', query.cursor);
return this.get(`/api/v1/sources/${encodeURIComponent(id)}/browse?${params}`, options);
}
async *searchSource(id: string, query: { query: string; sessionId: string; cursor?: string; filters?: Record<string, string>; resultLimit?: number }, options: RequestOptions = {}): AsyncGenerator<SourcePage> {
yield* this.catalogStream('/api/v1/sources/' + encodeURIComponent(id) + '/search', query, options);
}
private async *catalogStream(path: string, query: unknown, options: RequestOptions): AsyncGenerator<SourcePage> {
const generation = this.sessionGeneration, controller = new AbortController();
const { signal, dispose } = combineAbortSignals(options.signal ? [options.signal, controller.signal] : [controller.signal]);
this.streams.add(controller);
try {
const response = await this.request(path, 'POST', query, { ...options, signal, stream: true });
if (!response.stream) throw new ApiError('server', '搜索结果流缺失', 'INVALID_STREAM');
for await (const event of eventStream(response.stream, signal)) {
this.assertSession(generation);
let data: unknown;
try { data = JSON.parse(event.data); } catch { throw new ApiError('server', '搜索事件格式错误', 'INVALID_STREAM'); }
if (event.event === 'done') return;
if (event.event === 'error') {
const error = data as { message?: string; code?: string; status?: number };
throw new ApiError(errorForStatus(error.status ?? 500), error.message ?? '搜索失败', error.code ?? 'SEARCH_FAILED', error.status ?? 500);
}
if (event.event !== 'results' || !data || typeof data !== 'object' || !Array.isArray((data as SourcePage).items)) {
throw new ApiError('server', '无效的搜索事件', 'INVALID_STREAM');
}
yield data as SourcePage;
}
throw new ApiError('offline', '搜索连接中断,已保留收到的结果,可继续搜索。', 'STREAM_INTERRUPTED');
} catch (error) {
// An idle stream may abort before yielding another event. Preserve the
// account-change contract on that path as well as between events.
this.assertSession(generation);
throw error;
} finally { controller.abort(); dispose(); this.streams.delete(controller); }
}
async sourceDetail(id: string, ref: string): Promise<SourceEntry> {
return this.get(`/api/v1/sources/${encodeURIComponent(id)}/entries?ref=${encodeURIComponent(ref)}`);
}
async acquireSource(id: string, entryRef: string, optionId?: string): Promise<SourceAcquisition> {
return this.call(`/api/v1/sources/${encodeURIComponent(id)}/acquire`, 'POST', { entryRef, ...(optionId ? { optionId } : {}) });
}
async subscriptions(): Promise<ChapterSubscription[]> {
return (await this.get<{ subscriptions: ChapterSubscription[] }>('/api/v1/subscriptions')).subscriptions;
}
async configureSubscription(id: string, patch: { enabled?: boolean; intervalMinutes?: number; acknowledge?: boolean }): Promise<void> {
await this.call(`/api/v1/books/${encodeURIComponent(id)}/subscription`, 'PATCH', patch);
}
async listBooks(query: ListQuery = {}, options: RequestOptions = {}): Promise<BookListPage> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
params.set(key, String(value));
}
const suffix = params.size > 0 ? `?${params.toString()}` : '';
return this.get<BookListPage>(`/api/v1/books${suffix}`, options);
}
async getBook(id: string, options: RequestOptions = {}): Promise<{ book: Book; progress: Progress | null }> {
return this.get(`/api/v1/books/${encodeURIComponent(id)}`, options);
}
async manifest(id: string, options: RequestOptions = {}): Promise<Manifest> {
return this.get<Manifest>(`/api/v1/books/${encodeURIComponent(id)}/manifest`, options);
}
async refreshPublication(id: string, options: RequestOptions = {}): Promise<BookContent> {
return this.call<BookContent>(`/api/v1/books/${encodeURIComponent(id)}/refresh`, 'POST', undefined, options);
}
/**
* One addressable resource, as bytes.
*
* `ref` is opaque and must be passed through unchanged: it is whatever the
* manifest handed out (`xhtml:OEBPS/ch1.xhtml`, `page:17`), and a chapter is
* never addressed by index because an index means a different chapter in a
* different window.
*/
async asset(id: string, ref: string, options: RequestOptions = {}): Promise<Blob> {
const response = await this.request(
`/api/v1/books/${encodeURIComponent(id)}/assets?ref=${encodeURIComponent(ref)}`,
'GET', undefined, { ...options, binary: true },
);
if (response.status >= 400) {
throw new ApiError(errorForStatus(response.status), 'resource request failed', 'ASSET_FAILED', response.status);
}
const bytes = response.bytes ?? new Uint8Array();
// A Blob rather than the raw view: callers hand chapters to a frame as text
// and pages to an `<img>` as bytes, and the copy is what makes that safe.
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return new Blob([copy.buffer]);
}
/**
* One window of a book's addressable structure.
*
* Used to jump to a chapter that is not in the loaded window: the response
* carries both the items and the group metadata, so the client can splice it
* into the whole-book ordering without re-reading every earlier window.
*/
async items(id: string, group?: number, options: RequestOptions = {}): Promise<BookContent> {
const query = group === undefined ? '' : `?group=${group}`;
return this.get<BookContent>(`/api/v1/books/${encodeURIComponent(id)}/items${query}`, options);
}
async searchBook(id: string, query: string, cursor: string | undefined, options: RequestOptions = {}): Promise<import('./types.ts').BookSearchPage> {
const params = new URLSearchParams({q:query}); if (cursor) params.set('cursor',cursor);
return this.get(`/api/v1/books/${encodeURIComponent(id)}/search?${params}`,options);
}
/**
* The book's complete table of contents.
*
* A separate call from `manifest` because the two want opposite things: a
* manifest is windowed so opening a 1200-chapter book is cheap, and a table of
* contents has to be whole to be worth showing. Reading the contents off a
* manifest is the mistake this endpoint exists to prevent.
*/
async toc(id: string, options: RequestOptions = {}): Promise<TocEntry[]> {
const result = await this.get<{ toc: TocEntry[] }>(
`/api/v1/books/${encodeURIComponent(id)}/toc`,
options,
);
return result.toc;
}
/** Marks a shelf book 想读 / 在读 / 已读, or clears the mark (`null`) so progress decides. */
async setReadingStatus(bookId: string, status: ReadingState | null, options: RequestOptions = {}): Promise<void> {
await this.call(`/api/v1/books/${encodeURIComponent(bookId)}/reading-status`, 'PUT', { status }, options);
}
async facets(options: RequestOptions = {}): Promise<Facets> {
return this.get<Facets>('/api/v1/library/facets', options);
}
/** Whether this instance has a server-side speech engine, and what it offers. */
async readingOverrides(id: string): Promise<ReadingOverrides> { return this.get('/api/v1/books/' + encodeURIComponent(id) + '/reading-overrides'); }
async saveReadingOverrides(id: string, value: ReadingOverrides): Promise<ReadingOverrides> { return this.call('/api/v1/books/' + encodeURIComponent(id) + '/reading-overrides','PUT',value); }
async undoReadingOverrides(id: string, version: number): Promise<ReadingOverrides> { return this.call('/api/v1/books/' + encodeURIComponent(id) + '/reading-overrides/undo','POST',{version}); }
async testSpeech(voice: string, speed: number, options: RequestOptions = {}): Promise<{ bytes: number; contentType: string; elapsedMs: number }> {
return this.call('/api/v1/tts/test', 'POST', { voice, speed }, options);
}
async ttsCapabilities(options: RequestOptions = {}): Promise<TtsCapabilities | null> {
try {
return await this.get<TtsCapabilities>('/api/v1/tts/voices', options);
} catch {
// An old server has no such route and a stale one answers 404; both mean
// the same thing to the caller, which is "no HTTP engine here".
return null;
}
}
/**
* The shelf's "继续阅读" strip: whole books, newest reading first.
*
* ## The two normalisations, and why they live here
*
* This endpoint is the one place where the server and the client disagreed about
* the *name* of a field, and the disagreement was invisible: the card is typed as
* `ContinueReadingItem extends Book` and reads `book.id`, while the server answered
* the progress row's `bookId`. `title` and `coverUrl` happen to be spelled the same
* in both shapes, so the card drew correctly, and the tap carried `id: undefined` —
* `#/book/undefined`, a 404, and "这本书不在书架上了" on a book that was plainly on
* the shelf behind the toast.
*
* The server is fixed, so this is not the repair; it is what stops the repair from
* being *deployed in halves*. The H5 bundle and the API version independently: an
* Android WebView can hold a cached client against a newer server, and a browser
* can hold a cached client against an older one. Normalising at the boundary means
* either combination works, rather than the six weeks in which one does not.
*
* A row with no id at all is **dropped**, not drawn: a card whose tap goes nowhere
* is precisely the reported symptom, and losing one shortcut out of twenty is a
* smaller failure than growing a control that lies about being one.
*/
async continueReading(limit = 20, options: RequestOptions = {}): Promise<ContinueReadingItem[]> {
const result = await this.get<{ items: Array<ContinueReadingItem & { bookId?: string }> }>(
`/api/v1/library/continue?limit=${limit}`, options,
);
return result.items.flatMap((item) => {
// `bookId` is the old spelling of `id`; `updatedAt` the old spelling of
// `lastReadAt`. See the type for the full history.
const id = item.id ?? item.bookId;
if (!id) return [];
return [{ ...item, id, lastReadAt: item.lastReadAt ?? item.updatedAt ?? null }];
});
}
// ---- library file manager ----
/**
* One directory of the library tree.
*
* `path` is library-relative and `''` means the root; the server resolves it
* through the same containment check every other filesystem access uses.
*/
async browse(path = '', page = 1, options: RequestOptions = {}): Promise<BrowseListing> {
/*
* `page` is sent even when it is 1, and the path is not sent when it is empty.
*
* The asymmetry is deliberate: an empty path *is* the root and the server
* defaults it, while a page of one is a position the client asked for — and a
* request that omits it says "whatever you think", which stops being the same
* request the day the default changes.
*/
const params = new URLSearchParams();
if (path) params.set('path', path);
params.set('page', String(page > 0 ? page : 1));
return this.get<BrowseListing>(`/api/v1/library/browse?${params.toString()}`, options);
}
async browseMove(paths: string[], target: string, options: RequestOptions = {}): Promise<{ moved: number; target: string }> {
return this.call('/api/v1/library/browse/move', 'POST', { paths, target }, options);
}
async browseRename(path: string, name: string, options: RequestOptions = {}): Promise<{ path: string }> {
return this.call('/api/v1/library/browse/rename', 'POST', { path, name }, options);
}
async browseMkdir(path: string, name: string, options: RequestOptions = {}): Promise<{ path: string }> {
return this.call('/api/v1/library/browse/mkdir', 'POST', { path, name }, options);
}
async browseDelete(paths: string[], options: RequestOptions = {}): Promise<{ removed: number }> {
return this.call('/api/v1/library/browse/delete', 'POST', { paths }, options);
}
/**
* Applies one metadata patch to many paths.
*
* A path may name a folder, which the server reads as "every book indexed
* inside it" — a series is organised as a folder, and asking for the folder's
* own row would find nothing. The client never expands a folder itself: that
* mapping is the index's, and a second implementation of it here would be a
* second thing to keep in step.
*/
async browseBatchMetadata(
paths: string[],
fields: Record<string, unknown>,
options: RequestOptions = {},
): Promise<BatchResult> {
return this.call('/api/v1/library/browse/metadata', 'POST', { paths, fields }, options);
}
/**
* Adds or removes books on this account's shelf.
*
* Nothing on disk changes. Kept a separate call from move/delete because the
* two are one word apart in a list of rows and could not be more different in
* consequence.
*
* **Two ways to name the target, and the caller must pick one.** The file
* manager has *paths* (that is what a row is), and a shelf card has a *book id*
* (that is what a card is).
*
* The `bookIds` form is the fix for 「从书架移除时,找不到「xxx」在磁盘上的路径」.
* The shelf used to send paths it had reconstructed by matching a book's *title*
* against filenames in the library root — a guess that fails whenever the
* metadata title is not the filename (which is the normal case for anything the
* scanner read a title out of) and whenever the file is in a subfolder or past
* the first page. The server has the real mapping in `book_files`, so the card
* sends the id it already holds and no title has to match anything.
*/
async browseBatchShelf(
target: { paths: string[] } | { bookIds: string[] },
action: ShelfAction,
options: RequestOptions = {},
): Promise<BatchResult> {
return this.call('/api/v1/library/browse/shelf', 'POST', { ...target, action }, options);
}
/**
* Whether the mount accepts an upload at all, and whether this user may upload
* (members only when the `library.memberUpload` setting is on); asked before
* sending bytes.
*/
async uploadProbe(options: RequestOptions = {}): Promise<{ writable: boolean; allowed: boolean }> {
const result = await this.get<{ writable: boolean; allowed?: boolean }>('/api/v1/library/upload', options);
return { writable: result.writable, allowed: result.allowed ?? true };
}
/**
* Uploads files into a library directory.
*
* `FormData`, not a hand-built body: a browser sets the multipart boundary and
* streams a `File` off disk, and reimplementing either would mean buffering a
* 400MB comic in memory on a phone.
*
* The progress hook rides on the request rather than replacing the transport.
* The browser itself cannot report upload progress — `fetch` has no such event
* in any shipping engine — so the H5 build simply never calls it and the UI
* shows "上传中…"; a host that can (the Android shell's native path) does, and
* the same screen then shows a real percentage. What must *not* happen is a
* second implementation of the upload that only one host exercises.
*/
async upload(
files: File[],
target: string,
onConflict: ConflictPolicy,
onProgress?: (fraction: number) => void,
options: RequestOptions = {},
): Promise<UploadResult> {
const form = new FormData();
for (const file of files) form.append('file', file, file.name);
if (target) form.append('path', target);
form.append('onConflict', onConflict);
// No `content-type` is set: only the host knows the multipart boundary, and
// a header naming `application/json` over a `FormData` leaves the server
// unable to parse a body that is perfectly well formed.
const response = await this.platform.transport.send({
url: '/api/v1/library/upload',
method: 'POST',
headers: { accept: 'application/json', ...this.authHeader() },
body: form,
...(onProgress ? { onUploadProgress: onProgress } : {}),
...(options.signal ? { signal: options.signal } : {}),
});
void this.queryClient.invalidateQueries({
queryKey: ['reader'],
predicate: query => affectedNavigationQuery('/api/v1/library/upload', String(query.queryKey[4])),
refetchType: 'none',
});
return response.json as UploadResult;
}
private authHeader(): Record<string, string> {
return this.session ? { authorization: `Bearer ${this.session.accessToken}` } : {};
}
// ---- content ----
async pdfSource(id: string, options: RequestOptions = {}): Promise<import('../formats/types.ts').PdfSource> {
const generation = this.sessionGeneration;
const path = `/api/v1/books/${encodeURIComponent(id)}/content`;
const metadata = await this.request(path, 'HEAD', undefined, options);
const length = Number(metadata.headers['content-length']);
if (!Number.isSafeInteger(length) || length <= 0 || metadata.headers['accept-ranges'] !== 'bytes') {
throw new Error('PDF 在线阅读需要服务器支持字节范围请求');
}
return {
length,
readRange: async (begin, end, signal) => {
this.assertSession(generation);
const response = await this.request(path, 'GET', undefined, {
binary: true, signal, headers: { range: `bytes=${begin}-${end - 1}` },
});
this.assertSession(generation);
if (response.status !== 206 || response.headers['content-range'] !== `bytes ${begin}-${end - 1}/${length}` || response.bytes?.byteLength !== end - begin) {
throw new Error('服务器返回了无效的 PDF 字节范围');
}
return response.bytes;
},
};
}
/** Authenticated book body. Not cacheable by the service worker: it needs a token. */
async bookBytes(id: string, options: RequestOptions = {}): Promise<Uint8Array> {
const response = await this.request(
`/api/v1/books/${encodeURIComponent(id)}/content`,
'GET',
undefined,
{ ...options, binary: true },
);
return response.bytes ?? new Uint8Array();
}
async coverBytes(coverUrl: string, options: RequestOptions = {}): Promise<Uint8Array> {
const response = await this.request(coverUrl, 'GET', undefined, { ...options, binary: true });
return response.bytes ?? new Uint8Array();
}
// ---- sync ----
async pull(since: number, bookId?: string, options: RequestOptions = {}): Promise<SyncPull> {
const params = new URLSearchParams({ since: String(since) });
if (bookId) params.set('bookId', bookId);
return this.get<SyncPull>(`/api/v1/sync?${params.toString()}`, options);
}
async push(
payload: { progress?: ProgressInput[]; notes?: NoteInput[]; since?: number },
options: RequestOptions = {},
): Promise<SyncPushResult> {
return this.call<SyncPushResult>('/api/v1/sync', 'POST', payload, options);
}
/**
* Single progress write. Cheaper than a batch and used for the periodic
* "still reading" report, where there is nothing to reconcile.
*/
/** Where this account has been in a book, newest first (server keeps the latest 50). */
async progressHistory(bookId: string, options: RequestOptions = {}): Promise<Progress[]> {
const result = await this.get<{ history: Progress[] }>(
`/api/v1/sync/progress/${encodeURIComponent(bookId)}/history`,
options,
);
return result.history;
}
// ---- 时长统计 ----
/** Active reading seconds per book per 15-minute slot; the server keeps the larger total per slot. */
async recordReadingTime(clientId: string, entries: ReadingTimeEntry[], options: RequestOptions = {}): Promise<{ accepted: number; rejected: number }> {
return this.call('/api/v1/stats/reading-time', 'POST', { clientId, entries }, options);
}
async readingStats(query: TimeStatsQuery, options: RequestOptions = {}): Promise<ReadingStats> {
return this.get<ReadingStats>(`/api/v1/stats/reading?${statsParams(query)}`, options);
}
/** Administrators: every account's reading total in the range. */
async readingStatsUsers(query: TimeStatsQuery, options: RequestOptions = {}): Promise<UserReadingTotal[]> {
return (await this.get<{ users: UserReadingTotal[] }>(`/api/v1/stats/reading/users?${statsParams(query)}`, options)).users;
}
async putProgress(progress: ProgressInput, options: RequestOptions = {}): Promise<Progress | null> {
const result = await this.call<{ progress: Progress | null }>(
`/api/v1/sync/progress/${encodeURIComponent(progress.bookId)}`,
'PUT',
progress,
options,
);
return result.progress;
}
async getProgress(bookId: string, options: RequestOptions = {}): Promise<Progress | null> {
const result = await this.get<{ progress: Progress | null }>(
`/api/v1/sync/progress/${encodeURIComponent(bookId)}`,
options,
);
return result.progress;
}
async listNotes(bookId?: string, since = 0, options: RequestOptions = {}): Promise<Note[]> {
const params = new URLSearchParams();
if (bookId) params.set('bookId', bookId);
if (since > 0) params.set('since', String(since));
const query = params.size > 0 ? `?${params.toString()}` : '';
const result = await this.get<{ notes: Note[] }>(`/api/v1/notes${query}`, options);
return result.notes;
}
async createNote(note: NoteInput, options: RequestOptions = {}): Promise<Note> {
return this.call<Note>('/api/v1/notes', 'POST', note, options);
}
async deleteNote(id: string, options: RequestOptions = {}): Promise<void> {
await this.call(`/api/v1/notes/${encodeURIComponent(id)}`, 'DELETE', undefined, options);
}
// ---- transport plumbing ----
/** Media modules share account renewal without coupling to reading DTOs. */
mediaRequest<T>(path: string, method = 'GET', body?: unknown, options: RequestOptions = {}): Promise<T> {
if (!path.startsWith('/api/v1/media/')) throw new Error('invalid media API path');
if (method === 'GET' && body === undefined) return this.get<T>(path, options);
return this.call<T>(path, method, body, options);
}
/** Force the next screen load to read the server while retaining cache data. */
refreshQueries(): void {
void this.queryClient.invalidateQueries({ queryKey: ['reader'], refetchType: 'none' });
}
businessSettingsRequest<T>(path='',method='GET',body?:unknown,signal?:AbortSignal):Promise<T>{
const requestPath = '/api/v1/admin/settings'+(path?'/'+path:'');
const options = signal ? { signal } : {};
return method === 'GET' && body === undefined
? this.get<T>(requestPath, options)
: this.call<T>(requestPath, method, body, options);
}
async businessTtsPreview(values:Record<string,unknown>,voice:string):Promise<Blob>{
const result=await this.request('/api/v1/admin/settings/tts/preview','POST',{values,voice},{binary:true});
return new Blob([new Uint8Array(result.bytes??[]).buffer],{type:result.headers['content-type']||'audio/mpeg'});
}
async businessAiModels(values?:{baseUrl?:string;apiKey?:string}):Promise<string[]> { return (await this.call<{models:string[]}>('/api/v1/admin/ai/models','POST',values??{})).models; }
/** What the server's AI can do for a reader; older servers answer 404. */
async aiCapabilities(options: RequestOptions = {}): Promise<{ summary: boolean; scan?: boolean }> { return this.get('/api/v1/ai/capabilities', options); }
async aiSummary(bookId:string,chapterId:string,content:string):Promise<{summary:string;cached:boolean}>{ return this.call('/api/v1/ai/summary','POST',{bookId,chapterId,content}); }
private async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
if (!isNavigationQuery(path)) return (await this.request(path, 'GET', undefined, options)).json as T;
const generation = this.sessionGeneration;
const key = readerQueryKey(this.currentBaseUrl, this.session?.user.id, this.queryScope, path);
let result: T;
try {
result = await this.queryClient.fetchQuery<T>({
queryKey: key,
// Keep the caller's AbortSignal semantics intact. These class-based
// screens already abort requests when disposed; letting fetchQuery own the
// signal would turn normal auth/session transitions into CancelledError.
queryFn: () => this.request(path, 'GET', undefined, options)
.then(response => response.json as T),
// Availability-bearing media resources are shared while in flight but
// always revalidated, so an explicit refresh reaches the server.
...(isLiveNavigationQuery(path) ? { staleTime: 0 } : {}),
});
} catch (error) {
// A QueryClient cancellation (cache cleared on account change) or a raw
// AbortError is the same outcome as an aborted transport request.
const aborted = error instanceof ApiError ? error.kind === 'aborted' : isCancelledError(error) || isAbortError(error);
if (!aborted) throw error;
// fetchQuery shares one in-flight request per path, run with the *first*
// caller's signal. When that caller goes away (a screen replaced right after
// login), a later caller that is still live must not inherit its abort.
if (options.signal?.aborted) throw error instanceof ApiError ? error : abortedError();
this.assertSession(generation);
result = (await this.request(path, 'GET', undefined, options)).json as T;
}
// A response that completed after login/logout must never escape through a
// cache promise, even when QueryClient returned a previously resolved value.
this.assertSession(generation);
return result;
}
private async call<T>(
path: string,
method: string,
body?: unknown,
options: RequestOptions = {},
): Promise<T> {
const response = await this.request(path, method, body, options);
if (method !== 'GET') {
void this.queryClient.invalidateQueries({
queryKey: ['reader'],
predicate: query => affectedNavigationQuery(path, String(query.queryKey[4])),
refetchType: 'none',
});
}
return response.json as T;
}
private async request(
path: string,
method: string,
body: unknown,
options: RequestOptions & { binary?: boolean; stream?: boolean; headers?: Record<string, string> } = {},
): Promise<import('../core/platform.ts').HttpResponse> {
const generation = this.sessionGeneration;
const assertCurrent = (): void => this.assertSession(generation);
const attempt = async (token: string | null) => {
assertCurrent();
const headers: Record<string, string> = { accept: options.stream ? 'text/event-stream' : options.binary ? '*/*' : 'application/json' };
Object.assign(headers, options.headers);
const multipart = typeof FormData !== 'undefined' && body instanceof FormData;
if (body !== undefined && !multipart) headers['content-type'] = 'application/json';
if (token) headers.authorization = `Bearer ${token}`;
return this.platform.transport.send({
url: path,
method,
headers,
...(body !== undefined ? { body: multipart ? body as FormData : JSON.stringify(body) } : {}),
...(options.binary ? { binary: true } : {}),
...(options.stream ? { stream: true } : {}),
...(options.signal ? { signal: options.signal } : {}),
});
};
if (!path.startsWith('/api/v1/auth/') && path !== '/api/v1/instance') {
try { await this.renewSessionIfNeeded(); }
catch (error) {
if (error instanceof ApiError && error.isAuthFailure) throw error;
assertCurrent();
if (!(error instanceof ApiError) || error.isAuthFailure || (this.session?.accessTokenExpiresAt ?? 0) <= Date.now()) throw error;
}
assertCurrent();
}
const token = this.session?.accessToken ?? null;
try {
const response = await attempt(token);
if (generation !== this.sessionGeneration) await response.stream?.cancel().catch(() => {});
assertCurrent();
return response;
} catch (caught) {
assertCurrent();
// Transports other than FetchTransport may still reject with the signal's
// raw reason; callers only ever see ApiError('aborted') for cancellation.
const err = caught instanceof ApiError ? caught : abortFailure(caught, options.signal) ?? caught;
if (!(err instanceof ApiError)) throw err;
if (err.kind === 'aborted') throw err;
this.platform.reportError?.(err);
// Only an expired access token is worth a retry, and only if there is a
// refresh token to spend. `TOKEN_INVALID` means the signature or payload
// is wrong — refreshing would not help and could burn the refresh chain.
const retryable =
!options.noRetry &&
err.status === 401 &&
(err.code === 'TOKEN_EXPIRED' || err.code === '' || err.code === 'NO_TOKEN') &&
this.session?.refreshToken !== undefined;
if (!retryable) {
if (err.isAuthFailure) await this.clearSessionIfCurrent(generation);
throw err;
}
const refreshed = await this.refreshSession(generation);
assertCurrent();
try {
const response = await attempt(refreshed.accessToken);
if (generation !== this.sessionGeneration) await response.stream?.cancel().catch(() => {});
assertCurrent();
return response;
} catch (retryErr) {
assertCurrent();
if (retryErr instanceof ApiError && retryErr.isAuthFailure) await this.clearSessionIfCurrent(generation);
throw retryErr;
}
}
}
/** De-duplicated refresh: concurrent callers share one rotation. */
private refreshSession(generation: number): Promise<Session> {
this.assertSession(generation);
if (this.refreshPromise) return this.refreshPromise;
const refreshToken = this.session?.refreshToken;
if (!refreshToken) return Promise.reject(new ApiError('unauthorized', 'no refresh token'));
this.refreshPromise = (async () => {
try {
const response = await this.platform.transport.send({
url: '/api/v1/auth/refresh',
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ refreshToken }),
});
this.assertSession(generation);
const session = response.json as Session;
if (!session?.accessToken) throw new ApiError('unauthorized', 'refresh returned no session');
if (session.user.id !== this.session?.user.id) throw new ApiError('unauthorized', 'refresh returned another account');
const previous=this.session?.user;
if(previous?.features)session.user={...session.user,features:previous.features,...(previous.mediaState?{mediaState:previous.mediaState}:{})};
await this.setSession(session, true);
this.assertSession(generation);
return session;
} catch (err) {
this.assertSession(generation);
if (err instanceof ApiError && err.isAuthFailure) {
// A rejected refresh token is terminal: the user must sign in again.
await this.clearSessionIfCurrent(generation);
}
throw err;
} finally {
if (generation === this.sessionGeneration) this.refreshPromise = null;
}
})();
return this.refreshPromise;
}
private assertSession(generation: number): void {
if (generation !== this.sessionGeneration) {
throw new ApiError('aborted', 'account or server changed', 'ACCOUNT_CHANGED');
}
}
private async clearSessionIfCurrent(generation: number): Promise<void> {
this.assertSession(generation);
await this.setSession(null);
// Clearing changes the generation once. A further change while persistent
// storage was being written belongs to a new login and invalidates this error.
this.assertSession(generation + 1);
}
}
export { ApiError, errorForStatus, parseErrorBody };
export interface ReadingTimeEntry { bookId: string; slot: number; seconds: number }
export type TimeStatsGroup = 'day' | 'month' | 'year';
export interface TimeStatsQuery { from: number; to: number; tz: number; group: TimeStatsGroup }
export interface ReadingStats {
series: Array<{ key: string; seconds: number }>;
works: Array<{ id: string; title: string; author: string; coverUrl: string | null; seconds: number; lastAt: number }>;
total: number;
}
export interface UserReadingTotal { userId: string; username: string; displayName: string; seconds: number }
export function statsParams(query: TimeStatsQuery): string {
return new URLSearchParams({ from: String(query.from), to: String(query.to), tz: String(query.tz), group: query.group }).toString();
}