Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,22 +381,29 @@ player's first admin-only UI (Bridge decision 9) and its one Bridge kind, `sidec
costs are only defaults), `writable`, `can_describe?` (Phase 1c; the player queues no
description), `has {community, local, community_unknown?}` (each `{characters, recaps,
description?}`), `earlier` (up to 10 earlier series books without recaps, each costed),
`open_job`). `client.ts`:
`open_job`: the book's open generate job, else (Phase 1d) the open job of another kind that
refuses one now, a queued or working `chapters.detect`; its `kind` says which). `client.ts`:
`bridgeJobs(lib, path)` (`GET /admin/bridge/jobs`, first page), `queueBridgeJob` (201 new, 200
the open one already queued), `cancelBridgeJob`, `bridgeEstimate` (the upstream budget,
`UPSTREAM_TIMEOUT_MS`); refusals `library_not_writable` and `kind_unavailable` are
`API_ERROR_CODES` (`bridgeRefusalKey` words them; never `ApiError.message`).
`UPSTREAM_TIMEOUT_MS`); refusals `library_not_writable`, `kind_unavailable` and (Phase 1d)
`detect_open` are `API_ERROR_CODES` (`bridgeRefusalKey` words them; never `ApiError.message`).
- **Hooks** (`hooks.ts`, `qk.bridgeJobs` / `qk.bridgeEstimate`): `useBridgeJobs(lib, path,
{enabled, active, select})` asks only a server with the flag and only while `enabled` (a
non-admin would get a 403), `retry: false`; it POLLS (`bridgePollInterval`,
`src/lib/bridge-jobs.ts`) only while a `sidecars.generate` job of the book is open and the
surface is `active` with the app foregrounded: `BRIDGE_POLL_MS` (10 s) while claimed or
`src/lib/bridge-jobs.ts`) only while a `sidecars.generate` or (Phase 1d) `chapters.detect`
job of the book is open and the surface is `active` with the app foregrounded: `BRIDGE_POLL_MS` (10 s) while claimed or
running, `BRIDGE_IDLE_POLL_MS` (60 s) while queued or parked, never otherwise (the answer
that sees the job end stops it). When a read first sees the newest generate job `done`,
`refreshAfterFinish` invalidates the book's `/meta` (every variant) and its item (under any
path that names it) and WAITS for them before the answer lands, once per finish per cache
(`finishesActedOn`), so the tabs and `generated` are in before the status goes and the offer
never flashes back. `useBridgeEstimate` (`staleTime: 0`, read afresh each time the
that sees the job end stops it). When a read first sees the newest job that wrote the book
`done` (`latestDone`: a generate job, or the console's `sidecars.describe` or
`chapters.detect`), `refreshAfterFinish` invalidates the book's `/meta` (every variant), its
item and its chapters (under any path that names them) and WAITS for them before the answer
lands, once per finish per cache (`finishesActedOn`), so the tabs, `generated` and the
chapters a Bridge found are in before the status goes and the offer never flashes back.
Mid-run (Phase 1d: a generate run saves the chapters it found long before its end),
`refreshMidRun` reads the book's chapters again once per step a running generate job
reports past chapter detection (`midRunCheckpoint`), only while the cached answer has no
chapters of its own (at most one, no `chapters_source`).
`useBridgeEstimate` (`staleTime: 0`, read afresh each time the
confirmation opens), `useQueueBridgeJob` / `useCancelBridgeJob` (`CapabilityError` without
the flag; the answer goes into the book's job list, a cancel's 409 reads it again).
`useCapabilityQuery` gained `retry`, `select` and a query-aware `refetchInterval` for them.
Expand All @@ -415,9 +422,14 @@ player's first admin-only UI (Bridge decision 9) and its one Bridge kind, `sidec
and agent cost of every run it queues, `runEstimate`, a subscription's note, where the
results appear, and a switch, on by default, to queue the series' earlier books without
recaps first, `queueOrder` / `queueInOrder`, stopping at the first refusal; Generate held
back with `blockedReason` while the library isn't writable or the agent is KNOWN not ready),
back with `blockedReason` while the library isn't writable, the estimate's `open_job` is a
`chapters.detect` (Phase 1d: the server would answer 409 `detect_open`) or the agent is
KNOWN not ready),
`GenerateStatus` (polls while its screen is focused: phase, stage words `bridge.stage.*`
from `stageOf`, counts, ETA, a park's reason `parkReason` and when it resumes, a failure,
from `stageOf` (Phase 1d adds `chapter_detecting` and the park `chapters_not_detected`;
a `writing`/`rescanning` step sent with an engine stage before `ready`, the early chapters
save, reads as that stage),
counts, ETA, a park's reason `parkReason` and when it resumes, a failure,
Cancel, or "Try again", re-queueing, once it failed) and `GenerateOffer` (button or status under the companion's empty Who's
who / Story so far). The book page wires `useGenerate` into `HeroActions` with
`GenerateStatus` under the actions. Costs format with `formatUsd` (`src/lib/format.ts`).
Expand Down Expand Up @@ -460,8 +472,14 @@ the playing book; the rows' place is `rowAt`, previous/next for a book not playi
own `nextSegmentStart`/`previousSegmentStart`) then the rows (`chapterList`: the real chapters at their corrected starts, a
long chapterless file's 30-minute parts as the player makes them, else the files), ending with a
quiet globe caption ("Chapters from the AudioSilo community database") only when the chapters
answer says `chapters_source: "community"` and the rows are those chapters (not parts or files). Bookmarks and
Notes are the shared `AnnotationSection` (below), History is `HistorySection` over the shared
answer says `chapters_source: "community"` and the rows are those chapters (not parts or files),
or a server-glyph caption ("Chapters found in the narration on this server") for
`chapters_source: "generated"` (Bridge Phase 1d: a Bridge found them in a chapterless book's
narration; same rule). `knownChaptersSource` (`book-page-model.ts`) reads the answer's open
`chapters_source` (an own-key lookup: a source this build does not know is none, so no
caption); the tab keeps the rule that a caption goes only under real chapters, and one
`SOURCE_CAPTION` table (`satisfies Record<ChaptersSource, ...>`) picks the words and glyph.
Bookmarks and Notes are the shared `AnnotationSection` (below), History is `HistorySection` over the shared
listening sessions. Details (`book-details-tab.tsx`): a `Notice` (`ui/notice.tsx`) saying direct
play / converted for this browser (web, `useNeedsWebTranscode`) / plays from this device, the files table ("about N kbps" = size * 8 / duration, folded past 6),
and the path progress keys on. The **aside** (`book-aside.tsx`): About (`BookAbout`, its words
Expand Down
43 changes: 25 additions & 18 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ describe('ApiClient', () => {
expect(apiErrorKind(new ApiError(403, 'x', 'kid_account'))).toBe('kid_account');
// A member the admin disabled (their session or pairing): not the generic 409.
expect(apiErrorKind(new ApiError(409, 'x', 'member_disabled'))).toBe('member_disabled');
// A generate job for a book whose chapters are still being found (Bridge Phase 1d).
expect(apiErrorKind(new ApiError(409, 'x', 'detect_open'))).toBe('detect_open');
// A code the app doesn't know reads by its status.
expect(apiErrorKind(new ApiError(409, 'x', 'collection_full'))).toBe('conflict');
expect(apiErrorKind(new ApiError(429, 'too many attempts'))).toBe('too_many');
Expand Down Expand Up @@ -560,24 +562,29 @@ describe('ApiClient', () => {
],
};

it('passes chapters_source through on the chapters and item envelopes', async () => {
const fetchMock = installFetch((url) => ({
status: 200,
body: url.includes('/chapters')
? { ...chaptersBody, chapters_source: 'community' }
: { id: 9, rel_path: 'Myths/Mythos', chapters_source: 'community' },
}));
const c = new ApiClient('https://h', 'tok');
const data = await c.chapters(2, 'Myths/Mythos');
expect(String(fetchMock.mock.calls[0][0])).toContain(
'/libraries/2/chapters?path=Myths%2FMythos',
);
// Typed reads, so a misnamed mirror field fails the type check, not only a match.
expect(data.chapters_source).toBe('community');
expect(data.chapters[0].title).toBe('Chaos');
const book = await c.item(2, 'Myths/Mythos');
expect(book.chapters_source).toBe('community');
});
// "community": a community recording's list; "generated" (Bridge Phase 1d): chapters a
// Bridge found in the narration of a chapterless book.
it.each(['community', 'generated'] as const)(
'passes chapters_source %s through on the chapters and item envelopes',
async (source) => {
const fetchMock = installFetch((url) => ({
status: 200,
body: url.includes('/chapters')
? { ...chaptersBody, chapters_source: source }
: { id: 9, rel_path: 'Myths/Mythos', chapters_source: source },
}));
const c = new ApiClient('https://h', 'tok');
const data = await c.chapters(2, 'Myths/Mythos');
expect(String(fetchMock.mock.calls[0][0])).toContain(
'/libraries/2/chapters?path=Myths%2FMythos',
);
// Typed reads, so a misnamed mirror field fails the type check, not only a match.
expect(data.chapters_source).toBe(source);
expect(data.chapters[0].title).toBe('Chaos');
const book = await c.item(2, 'Myths/Mythos');
expect(book.chapters_source).toBe(source);
},
);

it('leaves chapters_source undefined when the server sends none (files or older server)', async () => {
installFetch(() => ({ status: 200, body: chaptersBody }));
Expand Down
10 changes: 7 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,12 @@ const API_ERROR_CODES = [
'member_disabled',
'kid_account',
'demo_account',
// The Bridge's admin routes: queueing for a library the Bridge can't write (409), and a
// kind the paired Bridge doesn't offer (409).
// The Bridge's admin routes: queueing for a library the Bridge can't write (409), a kind
// the paired Bridge doesn't offer (409), and (Bridge Phase 1d) a generate job for a book
// whose chapters a `chapters.detect` job is still finding (409).
'library_not_writable',
'kind_unavailable',
'detect_open',
] as const;

export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
Expand Down Expand Up @@ -1172,7 +1174,9 @@ export class ApiClient {
/** Queue a job (201), or get the open one already queued for that kind and book (200):
* the answer is the job either way. Refused with a 409 `library_not_writable` when the
* Bridge can't write the library, a 409 `kind_unavailable` when it doesn't offer the
* kind, a 404 for no such book and a 400 for params the kind refuses. */
* kind, a 409 `detect_open` for a generate job while a `chapters.detect` job is queued or
* working for the book (Bridge Phase 1d), a 404 for no such book and a 400 for params the
* kind refuses. */
queueBridgeJob(job: BridgeJobRequest) {
return this.request<BridgeJob>('POST', '/admin/bridge/jobs', { body: job });
}
Expand Down
105 changes: 105 additions & 0 deletions src/api/hooks-bridge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,111 @@ describe('useBridgeJobs', () => {
expect(qc.getQueryState(OTHER_KEY)?.isInvalidated).toBe(false);
});

// Bridge Phase 1d: a finished run on a chapterless book wrote the chapters it found in the
// narration, so the book's chapters answer (under its path, or a part path the server
// answered with the book) is read again, and no other book's.
it("reads the book's chapters again under its path and a part path, and no other book's", async () => {
const { qc } = await mount(bridgeServer(), () => null);
const OWN_KEY = qk.chapters('c1', 1, 'Author/Book');
const PART_KEY = qk.chapters('c1', 1, 'Author/Book/Disc 1');
const OTHER_KEY = qk.chapters('c1', 1, 'Author/Other');
qc.setQueryData(OWN_KEY, { path: 'Author/Book', chapters: [] });
qc.setQueryData(PART_KEY, { path: 'Author/Book', chapters: [] });
qc.setQueryData(OTHER_KEY, { path: 'Author/Other', chapters: [] });
mockClients.c1.bridgeJobs.mockResolvedValue([
bridgeJob({ state: 'done', finished_at: '2026-10-15T10:05:00Z' }),
]);
const { result } = await renderHook(() => useJobsOf(), {
wrapper: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(qc.getQueryState(OWN_KEY)?.isInvalidated).toBe(true);
expect(qc.getQueryState(PART_KEY)?.isInvalidated).toBe(true);
expect(qc.getQueryState(OTHER_KEY)?.isInvalidated).toBe(false);
});

// The console's chapter detection (Bridge Phase 1d) writes the book's chapters too.
it("reads the book's chapters and item again once a chapters.detect job is done", async () => {
const { qc } = await mount(bridgeServer(), () => null);
const CHAPTERS_KEY = qk.chapters('c1', 1, 'Author/Book');
const ITEM_KEY = qk.item('c1', 1, 'Author/Book');
qc.setQueryData(CHAPTERS_KEY, { path: 'Author/Book', chapters: [] });
qc.setQueryData(ITEM_KEY, { rel_path: 'Author/Book' });
mockClients.c1.bridgeJobs.mockResolvedValue([
bridgeJob({ kind: 'chapters.detect', state: 'done', finished_at: '2026-10-15T10:05:00Z' }),
]);
const { result } = await renderHook(() => useJobsOf(), {
wrapper: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(qc.getQueryState(CHAPTERS_KEY)?.isInvalidated).toBe(true);
expect(qc.getQueryState(ITEM_KEY)?.isInvalidated).toBe(true);
});

// Bridge Phase 1d: a generate run saves the chapters it found long before its end, so a
// book still without chapters of its own has them read again mid-run, once per step.
it("reads a chapterless book's chapters again mid-run, once per step past detection", async () => {
const { qc } = await mount(bridgeServer(), () => null);
const OWN_KEY = qk.chapters('c1', 1, 'Author/Book');
const empty = { path: 'Author/Book', chapters: [] };
qc.setQueryData(OWN_KEY, empty);
const running = (progress: { step?: string; stage?: string }) => [
bridgeJob({ state: 'running', progress }),
];
// Still detecting: nothing can have been saved yet.
mockClients.c1.bridgeJobs.mockResolvedValue(
running({ step: 'chapter_detecting', stage: 'chapter_detecting' }),
);
const { result } = await renderHook(() => useJobsOf(), {
wrapper: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(qc.getQueryState(OWN_KEY)?.isInvalidated).toBe(false);

// Past it: read again.
mockClients.c1.bridgeJobs.mockResolvedValue(
running({ step: 'sanitizing', stage: 'sanitizing' }),
);
await result.current.refetch();
await waitFor(() => expect(qc.getQueryState(OWN_KEY)?.isInvalidated).toBe(true));

// The same step again: not again.
qc.setQueryData(OWN_KEY, empty);
await result.current.refetch();
expect(qc.getQueryState(OWN_KEY)?.isInvalidated).toBe(false);

// The early save's own step (a read just before it is followed by one after).
mockClients.c1.bridgeJobs.mockResolvedValue(
running({ step: 'rescanning', stage: 'sanitizing' }),
);
await result.current.refetch();
await waitFor(() => expect(qc.getQueryState(OWN_KEY)?.isInvalidated).toBe(true));
});

it('leaves a book with chapters of its own, or found ones, alone mid-run', async () => {
const { qc } = await mount(bridgeServer(), () => null);
const OWN_KEY = qk.chapters('c1', 1, 'Author/Book');
const PART_KEY = qk.chapters('c1', 1, 'Author/Book/Disc 1');
const chapter = { title: 'One', file_index: 0, file_path: 'a.m4b', start: 0, end: 60 };
qc.setQueryData(OWN_KEY, {
path: 'Author/Book',
chapters: [
{ ...chapter, book_offset: 0 },
{ ...chapter, title: 'Two', book_offset: 60 },
],
});
qc.setQueryData(PART_KEY, { path: 'Author/Book', chapters: [], chapters_source: 'generated' });
mockClients.c1.bridgeJobs.mockResolvedValue([
bridgeJob({ state: 'running', progress: { step: 'qa_sweep', stage: 'qa_sweep' } }),
]);
const { result } = await renderHook(() => useJobsOf(), {
wrapper: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(qc.getQueryState(OWN_KEY)?.isInvalidated).toBe(false);
expect(qc.getQueryState(PART_KEY)?.isInvalidated).toBe(false);
});

it('reads /meta again when it sees the job turn done, whatever the clocks say', async () => {
const { qc } = await mount(bridgeServer(), () => null);
// The device clock runs ahead: the answer looks newer than the finish.
Expand Down
Loading
Loading