mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
1009 lines
47 KiB
TypeScript
1009 lines
47 KiB
TypeScript
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();
|
||
}
|