Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
5411b64
docs: commit the phase 3 client slice plan
rsml Jul 20, 2026
8391468
refactor: move the http transport into the client api zone
rsml Jul 20, 2026
4c25e48
test: specify the typed request helper and its error type
rsml Jul 20, 2026
fe8d195
feat: add a typed request helper to the http transport
rsml Jul 21, 2026
5486c69
refactor: name the client timing values in one module
rsml Jul 21, 2026
21da6c5
test: specify the streaming helpers for the api zone
rsml Jul 21, 2026
3334789
feat: add the streaming helpers for the api zone
rsml Jul 21, 2026
b34d7d8
test: specify the media url builders
rsml Jul 21, 2026
112852b
feat: add the media url builders
rsml Jul 21, 2026
877a945
test: specify the persistence contract before splitting the store
rsml Jul 21, 2026
b34678b
refactor: split the store into one file per slice
rsml Jul 21, 2026
cc0e2ad
docs: correct the phase 3 plan against what the code actually does
rsml Jul 21, 2026
5f89287
refactor: type each sse stream by the events it actually sends
rsml Jul 21, 2026
ce09e3a
test: specify the books and chapters endpoints
rsml Jul 21, 2026
c8d7727
feat: add the books and chapters endpoint modules
rsml Jul 21, 2026
ef466d7
test: specify the creation, cover and import endpoints
rsml Jul 21, 2026
e2685df
feat: add the creation, cover and import endpoint modules
rsml Jul 21, 2026
fdf7d13
test: specify the settings, tasks and chat endpoints
rsml Jul 21, 2026
0a5be4c
feat: add the settings, tasks and chat endpoint modules
rsml Jul 21, 2026
a0d0202
test: specify the audiobook, profile and progress endpoints
rsml Jul 21, 2026
cb4bf8a
feat: add the audiobook, profile and progress endpoint modules
rsml Jul 21, 2026
7804ebd
refactor: infer every api request body from its server schema
rsml Jul 21, 2026
7e52209
feat: give the api zone a single entry point
rsml Jul 21, 2026
ed53b49
refactor: move every component into the feature it belongs to
rsml Jul 21, 2026
dd0a9d9
test: specify the library dialog machine before building it
rsml Jul 21, 2026
86e9096
feat: add the library dialog machine
rsml Jul 21, 2026
259104f
refactor: move creation wizard onto the typed api client
rsml Jul 21, 2026
388944f
refactor: move audiobook features onto the typed api client
rsml Jul 21, 2026
2ac2348
feat: move settings and profile onto the typed api client
rsml Jul 21, 2026
078df05
feat: move progress, quiz and chat onto the typed api client
rsml Jul 21, 2026
7974ea5
refactor: migrate reader network calls to the typed api client
rsml Jul 21, 2026
84f22bf
refactor: extract reader generation and scroll hooks out of ReaderPage
rsml Jul 21, 2026
96c7ab3
refactor: migrate BookOverviewModal and CoverGenerationModal to the t…
rsml Jul 21, 2026
9670a2f
refactor: migrate BackgroundTasksFooter and LibraryToolbar to typed a…
rsml Jul 21, 2026
91f49da
refactor: move the background-task stream onto subscribeToTaskEvents
rsml Jul 21, 2026
3027357
refactor: replace local Book interfaces in library components with th…
rsml Jul 21, 2026
4aa7745
feat: add useBooks, useHealthCheck, useElectronApiKeys and useLibrary…
rsml Jul 21, 2026
b151273
feat: add useBackgroundTaskEffects and useLibraryNavigation hooks
rsml Jul 21, 2026
8baaff8
refactor: extract LibraryPage from App.tsx
rsml Jul 21, 2026
65bcdb3
refactor: type the reader's book prop from the shared response
rsml Jul 21, 2026
c51db24
refactor: extract quiz and chapter completion hooks out of ReaderPage
rsml Jul 21, 2026
61b5fc1
refactor: split ReaderPage's render into phase components
rsml Jul 21, 2026
2e1fb01
refactor: move the wizard's suggestion lists into their own module
rsml Jul 21, 2026
8163b73
refactor: replace LibraryPage's per-dialog state with a single reducer
rsml Jul 21, 2026
64cc889
refactor: move library dialog and context menu markup into dialogs/
rsml Jul 21, 2026
ade2e1e
refactor: name the last two timing literals
rsml Jul 21, 2026
f38ef8d
feat: make the api boundary a build failure rather than a convention
rsml Jul 21, 2026
0175489
docs: record what the size targets actually cost
rsml Jul 21, 2026
478e815
feat: name every icon-only button for screen readers
rsml Jul 21, 2026
833b45a
feat: announce the generation stage as a live region
rsml Jul 21, 2026
c149669
feat: name the last unlabelled button
rsml Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,245 changes: 0 additions & 2,245 deletions client/App.tsx

This file was deleted.

152 changes: 152 additions & 0 deletions client/api/audiobook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { ApiError } from './http'
import {
getBookAudiobook,
generateAudiobook,
getEngineStatus,
installEngine,
listVoices,
revealAudiobook,
} from './audiobook'

/**
* The narration engine, its voices, and the audiobook generated for each
* book. installEngine gets the most attention here, since a 409 from that
* one endpoint is a success rather than a failure, and that is the one
* behavior in this module that is easy to get backwards.
*/

let fetchSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch')
})
afterEach(() => {
fetchSpy.mockRestore()
})

describe('getBookAudiobook', () => {
it('requests the per-book audiobook status', async () => {
const payload = { exists: true, generatedChapters: [1, 2], manifest: null }
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 }))

const result = await getBookAudiobook('ada')

const [url, init] = fetchSpy.mock.calls[0]
expect(url).toBe('/api/books/ada/audiobook')
expect((init as RequestInit).method).toBeUndefined()
expect(result).toEqual(payload)
})

it('omits the trace header, since this polls on an interval while generating', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ exists: false, generatedChapters: [], manifest: null }), { status: 200 }),
)

await getBookAudiobook('ada')

const headers = new Headers((fetchSpy.mock.calls[0][1] as RequestInit).headers)
expect(headers.has('X-Trace-Id')).toBe(false)
})
})

describe('generateAudiobook', () => {
it('posts the voice, speed, and remember or replace choices', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ taskId: 't1' }), { status: 200 }))
const body = { voiceId: 'am_michael', speed: 1.1, rememberAsDefault: true, confirmReplace: false }

const result = await generateAudiobook('ada', body)

const [url, init] = fetchSpy.mock.calls[0]
expect(url).toBe('/api/books/ada/audiobook')
expect((init as RequestInit).method).toBe('POST')
expect((init as RequestInit).body).toBe(JSON.stringify(body))
expect(result).toEqual({ taskId: 't1' })
})

it('throws an ApiError carrying the status and the reason the server gave', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'Audiobook already exists', exists: true }), { status: 409 }),
)

const failure = await generateAudiobook('ada', {}).catch((e: unknown) => e)

expect(failure).toBeInstanceOf(ApiError)
expect((failure as ApiError).status).toBe(409)
expect((failure as Error).message).toBe('Audiobook already exists')
})
})

describe('getEngineStatus', () => {
it('requests the narration engine status', async () => {
const payload = { installed: true, missing: { model: false, ffmpeg: false }, downloadSize: 0 }
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 }))

const result = await getEngineStatus()

const [url, init] = fetchSpy.mock.calls[0]
expect(url).toBe('/api/audiobook/status')
expect((init as RequestInit).method).toBeUndefined()
expect(result).toEqual(payload)
})
})

describe('installEngine', () => {
it('posts to the install endpoint', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ taskId: 't1' }), { status: 200 }))

await installEngine()

const [url, init] = fetchSpy.mock.calls[0]
expect(url).toBe('/api/audiobook/install')
expect((init as RequestInit).method).toBe('POST')
})

it('resolves rather than throwing when the engine is already installed or installing', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'Audiobook engine already installed' }), { status: 409 }),
)

await expect(installEngine()).resolves.toBeUndefined()
})

it('still throws an ApiError for a real failure', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'Disk full' }), { status: 500 }))

const failure = await installEngine().catch((e: unknown) => e)

expect(failure).toBeInstanceOf(ApiError)
expect((failure as ApiError).status).toBe(500)
expect((failure as Error).message).toBe('Disk full')
})
})

describe('listVoices', () => {
it('requests the voice list and unwraps it', async () => {
const voices = [
{ id: 'am_michael', name: 'Michael', language: 'American English', gender: 'Male', grade: 'A' },
]
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ voices }), { status: 200 }))

const result = await listVoices()

const [url, init] = fetchSpy.mock.calls[0]
expect(url).toBe('/api/audiobook/voices')
expect((init as RequestInit).method).toBeUndefined()
expect(result).toEqual(voices)
})
})

describe('revealAudiobook', () => {
it('posts to the reveal endpoint for one book', async () => {
const payload = { path: '/books/ada/audiobook/book.m4b', revealed: true }
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 }))

const result = await revealAudiobook('ada')

const [url, init] = fetchSpy.mock.calls[0]
expect(url).toBe('/api/books/ada/audiobook/reveal')
expect((init as RequestInit).method).toBe('POST')
expect(result).toEqual(payload)
})
})
88 changes: 88 additions & 0 deletions client/api/audiobook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { z } from 'zod'
import type { GenerateAudiobookBodySchema } from '@shared/contracts'
import type { AudiobookManifest } from '@shared/domain'
import type { AudiobookStatus, VoiceInfo } from '@shared/responses'
import { apiFetch, expectOk, request } from './http'

/**
* Endpoints for the narration engine, the voices it offers, and the
* audiobook generated for each book.
*
* Request bodies are inferred from the Zod schemas the server validates
* against, so a body this module sends cannot drift from what the route
* accepts. The schemas are imported as types only and compile away, so no
* validator reaches the browser bundle.
*/

/** The audiobook state for one book, covering whether it exists, how far narration has gotten, and its manifest. */
export interface BookAudiobookStatus {
exists: boolean
path?: string
generatedChapters: number[]
manifest: AudiobookManifest | null
}

/**
* Check whether a book has a generated audiobook and how far generation has progressed.
*
* Tracing is switched off because this call runs on a four second interval
* while an audiobook is generating. Adding the trace header would turn a
* CORS-simple GET into a preflighted one, doubling the request count for as
* long as the poll runs.
*/
export async function getBookAudiobook(bookId: string): Promise<BookAudiobookStatus> {
return request<BookAudiobookStatus>(`/api/books/${bookId}/audiobook`, { trace: false })
}

/** The voice, speed, and remember or replace choices generateAudiobook sends. */
export type GenerateAudiobookBody = z.infer<typeof GenerateAudiobookBodySchema>

/** What generateAudiobook resolves with once narration has been queued. */
export interface GenerateAudiobookResult {
taskId: string
}

/** Start narrating a book's chapters into a single audiobook file. */
export async function generateAudiobook(
bookId: string,
body: GenerateAudiobookBody,
): Promise<GenerateAudiobookResult> {
return request<GenerateAudiobookResult>(`/api/books/${bookId}/audiobook`, { method: 'POST', body })
}

/** Check whether the narration engine, meaning the model and ffmpeg, is installed. */
export async function getEngineStatus(): Promise<AudiobookStatus> {
return request<AudiobookStatus>('/api/audiobook/status')
}

/**
* Kick off the narration engine install.
*
* A 409 from the server means the engine is already installed or an install
* is already running. Either outcome is exactly what this call wants, so it
* resolves instead of throwing. request() would turn that response into an
* ApiError, so this goes through apiFetch directly and checks the status by
* hand.
*/
export async function installEngine(): Promise<void> {
const response = await apiFetch('/api/audiobook/install', { method: 'POST' })
if (response.status === 409) return
await expectOk(response, 'Failed to start the narrator install')
}

/** List the narrator voices available for the voice picker. */
export async function listVoices(): Promise<VoiceInfo[]> {
const { voices } = await request<{ voices: VoiceInfo[] }>('/api/audiobook/voices')
return voices
}

/** What revealAudiobook resolves with, meaning the file path and whether the OS reveal succeeded. */
export interface RevealAudiobookResult {
path: string
revealed: boolean
}

/** Ask the server to reveal a book's audiobook file in Finder or Explorer. */
export async function revealAudiobook(bookId: string): Promise<RevealAudiobookResult> {
return request<RevealAudiobookResult>(`/api/books/${bookId}/audiobook/reveal`, { method: 'POST' })
}
Loading
Loading