From 199b937a255017b0572649b013cfd48e615912af Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 19 Jan 2026 14:22:21 -0700 Subject: [PATCH] refactor: overhaul document storage and audiobook architecture - refactor(documents): implement stable SHA256-based document IDs - feat(library): add support for external document libraries - refactor(audiobook): consolidate server logic and migrate to V1 storage layout - feat(audiobook): add reset functionality and improve chapter management - fix(ui): enhance audiobook export modal with download/regenerate controls - test: add coverage for upload hashing and export reset flow --- README.md | 76 ++- playwright.config.ts | 15 +- src/app/api/audiobook/chapter/route.ts | 71 +-- src/app/api/audiobook/route.ts | 257 ++++++--- src/app/api/audiobook/status/route.ts | 83 +-- .../api/documents/library/content/route.ts | 104 ++++ src/app/api/documents/library/route.ts | 128 +++++ src/app/api/documents/route.ts | 219 +++++-- src/app/api/migrations/v1/route.ts | 135 +++++ src/app/epub/[id]/page.tsx | 31 +- src/app/html/[id]/page.tsx | 19 +- src/app/pdf/[id]/page.tsx | 30 +- src/components/AudiobookExportModal.tsx | 383 +++++++++---- src/components/DocumentSettings.tsx | 15 +- src/components/Footer.tsx | 1 + src/components/ProgressCard.tsx | 3 +- src/components/ProgressPopup.tsx | 4 +- src/components/SettingsModal.tsx | 61 +- src/components/doclist/DocumentPreview.tsx | 72 +-- src/components/player/SpeedControl.tsx | 47 +- src/components/player/VoicesControl.tsx | 132 +---- src/components/player/VoicesControlBase.tsx | 138 +++++ src/contexts/ConfigContext.tsx | 52 +- src/contexts/EPUBContext.tsx | 82 ++- src/contexts/PDFContext.tsx | 82 ++- src/hooks/epub/useEPUBDocuments.ts | 6 +- src/hooks/html/useHTMLDocuments.ts | 10 +- src/hooks/pdf/usePDFDocuments.ts | 6 +- src/lib/client.ts | 8 +- src/lib/dexie.ts | 366 +++++++++++- src/lib/pdf.ts | 48 +- src/lib/server/audiobook.ts | 215 +++++++ src/lib/server/docstore.ts | 538 ++++++++++++++++++ src/lib/server/library.ts | 48 ++ src/lib/sha256.ts | 158 +++++ src/types/client.ts | 11 + src/types/documents.ts | 5 + tests/export.spec.ts | 20 +- tests/upload.spec.ts | 39 ++ 39 files changed, 3095 insertions(+), 623 deletions(-) create mode 100644 src/app/api/documents/library/content/route.ts create mode 100644 src/app/api/documents/library/route.ts create mode 100644 src/app/api/migrations/v1/route.ts create mode 100644 src/components/player/VoicesControlBase.tsx create mode 100644 src/lib/server/audiobook.ts create mode 100644 src/lib/server/docstore.ts create mode 100644 src/lib/server/library.ts create mode 100644 src/lib/sha256.ts diff --git a/README.md b/README.md index 02dc60d..c2127a6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,6 @@ OpenReader WebUI is an open source text to speech document reader web app built docker run --name openreader-webui \ --restart unless-stopped \ -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ ghcr.io/richardr1126/openreader-webui:latest ``` @@ -54,15 +53,12 @@ OpenReader WebUI is an open source text to speech document reader web app built -e API_KEY=none \ -e API_BASE=http://host.docker.internal:8880/v1 \ -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ ghcr.io/richardr1126/openreader-webui:latest ``` - > **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. - Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. - > **Note:** The `openreader_docstore` volume is used to store server-side documents. You can mount a local directory instead. Or remove it if you don't need server-side documents. + > **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. ### 2. ⚙️ Configure the app settings in the UI: - Set the TTS Provider and Model in the Settings modal @@ -76,6 +72,60 @@ docker rm openreader-webui && \ docker pull ghcr.io/richardr1126/openreader-webui:latest ``` +### 📦 Volume mounts and Library import + +By default (no volume mounts), OpenReader will store its server-side files inside the container filesystem (which is lost if you remove the container). + +
+ + +**Persist server-side storage (`/app/docstore`)** + + + +Run the container with the volume mounted: +```bash +docker run --name openreader-webui \ + --restart unless-stopped \ + -p 3003:3003 \ + -v openreader_docstore:/app/docstore \ + ghcr.io/richardr1126/openreader-webui:latest +``` +This will create a Docker named volume `openreader_docstore` to persist all server-side files, including: + +- **Documents:** Stored under `/app/docstore/documents_v1` +- **Audiobook exports:** Stored under `/app/docstore/audiobooks_v1` + - Per-audiobook settings: `/app/docstore/audiobooks_v1/-audiobook/audiobook.meta.json` + - Chapters: `0001__.m4b` or `0001__<title>.mp3` (no per-chapter `.meta.json` files) +- **Settings** + +This ensures that your documents, exported audiobooks, and server-side settings are retained even if the container is removed or recreated. + +</details> + +<details open> +<summary> + +**Mount an external library folder (read-only recommended)** + +</summary> + +```bash +docker run --name openreader-webui \ + --restart unless-stopped \ + -p 3003:3003 \ + -v openreader_docstore:/app/docstore \ + -v /path/to/your/library:/app/docstore/library:ro \ + ghcr.io/richardr1126/openreader-webui:latest +``` +Seperate from the docstore volume, this will mount an external folder to `/app/docstore/library` (read-only recommended). This allows you to connect OpenReader to an existing library of documents. + +To import from the mounted library: **Settings → Documents → Server Library Import** + +> **Note:** Every file in the mounted volume is imported to the client browser's storage. Please ensure that the mounted library is not too large to avoid performance issues. + +</details> + ### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU) You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). @@ -84,7 +134,7 @@ You can run the Kokoro TTS API server directly with Docker. **We are not respons <details> <summary> -**Docker CPU** +**Kokoro-FastAPI (CPU)** </summary> @@ -110,7 +160,7 @@ docker run -d \ <details> <summary> -**Docker GPU** +**Kokoro-FastAPI (GPU)** </summary> @@ -225,9 +275,9 @@ Contributions are welcome! Fork the repository and submit a pull request with yo This project would not be possible without standing on the shoulders of these giants: - [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) model -- [Orpheus-TTS](https://huggingface.co/collections/canopylabs/orpheus-tts-67d9ea3f6c05a941c06ad9d2) model - [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) -- [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) +- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) +- [ffmpeg](https://ffmpeg.org) - [react-pdf](https://github.com/wojtekmaj/react-pdf) npm package - [react-reader](https://github.com/happyr/react-reader) npm package @@ -239,7 +289,8 @@ This project would not be possible without standing on the shoulders of these gi - **Framework:** Next.js (React) - **Containerization:** Docker -- **Storage:** Dexie + IndexedDB (in-browser local database) +- **Storage:** + - [Dexie.js](https://dexie.org/) IndexedDB wrapper for client-side storage - **PDF:** - [react-pdf](https://github.com/wojtekmaj/react-pdf) - [pdf.js](https://mozilla.github.io/pdf.js/) @@ -257,7 +308,10 @@ This project would not be possible without standing on the shoulders of these gi - [Deepinfra API](https://deepinfra.com) (Kokoro-82M, Orpheus-3B, Sesame-1B) - [Kokoro FastAPI TTS](https://github.com/remsky/Kokoro-FastAPI/tree/v0.0.5post1-stable) - [Orpheus FastAPI TTS](https://github.com/Lex-au/Orpheus-FastAPI) -- **NLP:** [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting +- **NLP:** + - [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting + - [cmpstr](https://github.com/remsky/cmpstr) String comparison library + - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for TTS timestamps (word-by-word highlighting) ## License diff --git a/playwright.config.ts b/playwright.config.ts index 5d20f5f..7b20bed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -36,17 +36,26 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + extraHTTPHeaders: { 'x-openreader-test-namespace': 'chromium' }, + }, }, { name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + use: { + ...devices['Desktop Firefox'], + extraHTTPHeaders: { 'x-openreader-test-namespace': 'firefox' }, + }, }, { name: 'webkit', - use: { ...devices['Desktop Safari'] }, + use: { + ...devices['Desktop Safari'], + extraHTTPHeaders: { 'x-openreader-test-namespace': 'webkit' }, + }, }, /* Test against mobile viewports. */ diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 5a43d47..1500efc 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -1,7 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { createReadStream, existsSync } from 'fs'; -import { readFile, unlink } from 'fs/promises'; +import { readdir, unlink } from 'fs/promises'; import { join } from 'path'; +import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { findStoredChapterByIndex } from '@/lib/server/audiobook'; + +function getAudiobooksRootDir(request: NextRequest): string { + const raw = request.headers.get('x-openreader-test-namespace')?.trim(); + if (!raw) return AUDIOBOOKS_V1_DIR; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; +} export async function GET(request: NextRequest) { try { @@ -23,25 +32,21 @@ export async function GET(request: NextRequest) { ); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); - // Read metadata to get format - const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); - if (!existsSync(metadataPath)) { + const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); + if (!chapter || !existsSync(chapter.filePath)) { return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); } - const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); - const format = metadata.format || 'm4b'; - const chapterPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`); - - if (!existsSync(chapterPath)) { - return NextResponse.json({ error: 'Chapter file not found' }, { status: 404 }); - } - // Stream the chapter file - const stream = createReadStream(chapterPath); + const stream = createReadStream(chapter.filePath); const readableWebStream = new ReadableStream({ start(controller) { @@ -60,13 +65,13 @@ export async function GET(request: NextRequest) { } }); - const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; - const sanitizedTitle = metadata.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + const mimeType = chapter.format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; + const sanitizedTitle = chapter.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); return new NextResponse(readableWebStream, { headers: { 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="${sanitizedTitle}.${format}"`, + 'Content-Disposition': `attachment; filename="${sanitizedTitle}.${chapter.format}"`, 'Cache-Control': 'no-cache', }, }); @@ -100,21 +105,19 @@ export async function DELETE(request: NextRequest) { ); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); - - // Read metadata to get format (if present) - const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); - - // Delete the chapter audio file (try both formats just in case) - const chapterPathM4b = join(intermediateDir, `${chapterIndex}-chapter.m4b`); - const chapterPathMp3 = join(intermediateDir, `${chapterIndex}-chapter.mp3`); - if (existsSync(chapterPathM4b)) await unlink(chapterPathM4b).catch(() => {}); - if (existsSync(chapterPathMp3)) await unlink(chapterPathMp3).catch(() => {}); - - // Delete metadata if present - if (existsSync(metadataPath)) { - await unlink(metadataPath).catch(() => {}); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; + const files = await readdir(intermediateDir).catch(() => []); + for (const file of files) { + if (!file.startsWith(chapterPrefix)) continue; + if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; + await unlink(join(intermediateDir, file)).catch(() => {}); } // Invalidate any combined "complete" files @@ -122,6 +125,8 @@ export async function DELETE(request: NextRequest) { const completeMp3 = join(intermediateDir, `complete.mp3`); if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {}); if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {}); + await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {}); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 17bf12a..5e30469 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -1,10 +1,20 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; -import { writeFile, readFile, mkdir, unlink, readdir, rm } from 'fs/promises'; +import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises'; import { existsSync, createReadStream } from 'fs'; -import { join } from 'path'; +import { basename, join } from 'path'; import { randomUUID } from 'crypto'; +import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; + +function getAudiobooksRootDir(request: NextRequest): string { + const raw = request.headers.get('x-openreader-test-namespace')?.trim(); + if (!raw) return AUDIOBOOKS_V1_DIR; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; +} interface ConversionRequest { chapterTitle: string; @@ -12,6 +22,7 @@ interface ConversionRequest { bookId?: string; format?: TTSAudiobookFormat; chapterIndex?: number; + settings?: AudiobookGenerationSettings; } async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> { @@ -119,38 +130,85 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> { }); } +function buildAtempoFilter(speed: number): string { + const clamped = Math.max(0.5, Math.min(speed, 3)); + // atempo supports 0.5..2.0 per filter; chain for >2.0 + if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`; + const second = clamped / 2; + return `atempo=2.0,atempo=${second.toFixed(3)}`; +} + export async function POST(request: NextRequest) { try { // Parse the request body const data: ConversionRequest = await request.json(); - const format = data.format || 'm4b'; + const requestedFormat = data.format || 'm4b'; - // Use docstore directory - const docstoreDir = join(process.cwd(), 'docstore'); - if (!existsSync(docstoreDir)) { - await mkdir(docstoreDir); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); } // Generate or use existing book ID const bookId = data.bookId || randomUUID(); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // Create intermediate directory - if (!existsSync(intermediateDir)) { - await mkdir(intermediateDir); + await mkdir(intermediateDir, { recursive: true }); + + const metaPath = join(intermediateDir, 'audiobook.meta.json'); + const incomingSettings = data.settings; + let existingSettings: AudiobookGenerationSettings | null = null; + try { + existingSettings = JSON.parse(await readFile(metaPath, 'utf8')) as AudiobookGenerationSettings; + } catch { + existingSettings = null; } + if (existingSettings) { + if (incomingSettings) { + const mismatch = + existingSettings.ttsProvider !== incomingSettings.ttsProvider || + existingSettings.ttsModel !== incomingSettings.ttsModel || + existingSettings.voice !== incomingSettings.voice || + existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || + existingSettings.postSpeed !== incomingSettings.postSpeed || + existingSettings.format !== incomingSettings.format; + if (mismatch) { + return NextResponse.json( + { error: 'Audiobook settings mismatch', settings: existingSettings }, + { status: 409 }, + ); + } + } + } else if (incomingSettings) { + await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)); + } + + const existingChapters = await listStoredChapters(intermediateDir, request.signal); + const existingFormats = new Set(existingChapters.map((c) => c.format)); + if (existingFormats.size > 1) { + return NextResponse.json( + { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, + { status: 400 }, + ); + } + + const format: TTSAudiobookFormat = + (existingFormats.values().next().value as TTSAudiobookFormat | undefined) ?? + existingSettings?.format ?? + incomingSettings?.format ?? + requestedFormat; + const postSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; + // Use provided chapter index or find the next available index robustly (handles gaps) let chapterIndex: number; if (data.chapterIndex !== undefined) { chapterIndex = data.chapterIndex; } else { - const files = await readdir(intermediateDir); - const indices = files - .map(f => f.match(/^(\d+)-chapter\.(m4b|mp3)$/)) - .filter((m): m is RegExpMatchArray => Boolean(m)) - .map(m => parseInt(m[1], 10)) - .sort((a, b) => a - b); + const indices = existingChapters.map((c) => c.index); // Find smallest non-negative integer not present let next = 0; for (const idx of indices) { @@ -165,43 +223,69 @@ export async function POST(request: NextRequest) { // Write input file (MP3 from TTS) const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`); - const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`); - const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); + const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`); + const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle); // Write the chapter audio to a temp file await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); - + + // We intentionally do not delete the existing chapter file up-front. This avoids a long + // window where the chapter is "missing" while ffmpeg is running (which can lead to + // partial/stale "complete.*" downloads). We clean up duplicates and invalidate the + // combined output only after the new chapter is written successfully. + if (format === 'mp3') { // For MP3, re-encode to ensure proper headers and consistent format await runFFmpeg([ '-y', // Overwrite output file without asking '-i', inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), '-c:a', 'libmp3lame', '-b:a', '64k', - '-metadata', `title=${data.chapterTitle}`, - chapterOutputPath + '-metadata', `title=${titleTag}`, + chapterOutputTempPath ], request.signal); } else { // Convert MP3 to M4B container with proper encoding and metadata await runFFmpeg([ '-y', // Overwrite output file without asking '-i', inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), '-c:a', 'aac', '-b:a', '64k', - '-metadata', `title=${data.chapterTitle}`, + '-metadata', `title=${titleTag}`, '-f', 'mp4', - chapterOutputPath + chapterOutputTempPath ], request.signal); } - // Get the duration and save metadata - const duration = await getAudioDuration(chapterOutputPath, request.signal); - await writeFile(metadataPath, JSON.stringify({ - title: data.chapterTitle, - duration, - index: chapterIndex, - format - })); + const probe = await ffprobeAudio(chapterOutputTempPath, request.signal); + const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal)); + + const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format)); + await unlink(finalChapterPath).catch(() => {}); + await rename(chapterOutputTempPath, finalChapterPath); + + // Remove any existing chapter files for this index (e.g., if the title changed and the + // filename changed) and invalidate the combined output now that the chapter is updated. + const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; + const finalChapterName = basename(finalChapterPath); + const existingFiles = await readdir(intermediateDir).catch(() => []); + for (const file of existingFiles) { + if (!file.startsWith(chapterPrefix)) continue; + if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; + if (file === finalChapterName) continue; + await unlink(join(intermediateDir, file)).catch(() => {}); + } + await unlink(join(intermediateDir, 'complete.mp3')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.m4b')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {}); + await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {}); + + // Ensure meta exists after first successful chapter. + if (!existingSettings && incomingSettings) { + await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => {}); + } // Clean up input file await unlink(inputPath).catch(console.error); @@ -238,69 +322,104 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - // Read all chapter metadata - const files = await readdir(intermediateDir); - const metaFiles = files.filter(f => f.endsWith('.meta.json')); - const chapters: { title: string; duration: number; index: number; format: string }[] = []; - - for (const metaFile of metaFiles) { - const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8')); - chapters.push(meta); - } + const stored = await listStoredChapters(intermediateDir, request.signal); + const chapters = stored.map((chapter) => ({ + title: chapter.title, + duration: chapter.durationSec ?? 0, + index: chapter.index, + format: chapter.format, + filePath: chapter.filePath, + })); if (chapters.length === 0) { return NextResponse.json({ error: 'No chapters found' }, { status: 404 }); } + const chapterFormats = new Set(chapters.map((chapter) => chapter.format)); + if (chapterFormats.size > 1) { + return NextResponse.json( + { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, + { status: 400 }, + ); + } + // Sort chapters by index chapters.sort((a, b) => a.index - b.index); - // Determine output format from existing chapter metadata to avoid mismatches - const chapterFormat = (chapters[0]?.format === 'mp3' || chapters[0]?.format === 'm4b') ? chapters[0].format : 'm4b'; - if (requestedFormat && requestedFormat !== chapterFormat) { - console.warn(`Requested format ${requestedFormat} differs from chapter format ${chapterFormat}. Using ${chapterFormat}.`); - } - const format = chapterFormat; + const format: TTSAudiobookFormat = requestedFormat ?? (chapters[0]?.format as TTSAudiobookFormat) ?? 'm4b'; const outputPath = join(intermediateDir, `complete.${format}`); + const manifestPath = join(intermediateDir, `complete.${format}.manifest.json`); const metadataPath = join(intermediateDir, 'metadata.txt'); const listPath = join(intermediateDir, 'list.txt'); - // Check if combined file already exists + const signature = chapters.map((chapter) => ({ + index: chapter.index, + fileName: basename(chapter.filePath), + })); + if (existsSync(outputPath)) { - // Stream the existing file - return streamFile(outputPath, format); + let cached: typeof signature | null = null; + try { + cached = JSON.parse(await readFile(manifestPath, 'utf8')) as typeof signature; + } catch { + cached = null; + } + + if (cached && JSON.stringify(cached) === JSON.stringify(signature)) { + return streamFile(outputPath, format); + } + + await unlink(outputPath).catch(() => {}); + await unlink(manifestPath).catch(() => {}); + } + + // Ensure we have chapter durations for chapter markers / ordering. + for (const chapter of chapters) { + if (chapter.duration && chapter.duration > 0) continue; + try { + const probe = await ffprobeAudio(chapter.filePath, request.signal); + if (probe.durationSec && probe.durationSec > 0) { + chapter.duration = probe.durationSec; + continue; + } + } catch {} + + try { + chapter.duration = await getAudioDuration(chapter.filePath, request.signal); + } catch { + chapter.duration = 0; + } } // Create chapter metadata file for M4B const metadata: string[] = []; let currentTime = 0; - - chapters.forEach((chapter) => { + + for (const chapter of chapters) { const startMs = Math.floor(currentTime * 1000); currentTime += chapter.duration; const endMs = Math.floor(currentTime * 1000); - metadata.push( - `[CHAPTER]`, - `TIMEBASE=1/1000`, - `START=${startMs}`, - `END=${endMs}`, - `title=${chapter.title}` - ); - }); + metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${chapter.title}`); + } await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); // Create list file for concat await writeFile( listPath, - chapters.map(c => `file '${join(intermediateDir, `${c.index}-chapter.${format}`)}'`).join('\n') + chapters.map(c => `file '${c.filePath}'`).join('\n') ); if (format === 'mp3') { @@ -322,7 +441,8 @@ export async function GET(request: NextRequest) { '-i', listPath, '-i', metadataPath, '-map_metadata', '1', - '-c:a', 'copy', + '-c:a', 'aac', + '-b:a', '64k', '-f', 'mp4', outputPath ], request.signal); @@ -334,6 +454,8 @@ export async function GET(request: NextRequest) { unlink(listPath).catch(console.error) ]); + await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => {}); + // Stream the file back to the client return streamFile(outputPath, format); @@ -390,8 +512,13 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // If directory doesn't exist, consider it already reset if (!existsSync(intermediateDir)) { diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 44e8fd6..f279372 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,8 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; -import { readdir, readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; -import type { TTSAudiobookFormat } from '@/types/tts'; +import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { listStoredChapters } from '@/lib/server/audiobook'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; +import { readFile } from 'fs/promises'; + +function getAudiobooksRootDir(request: NextRequest): string { + const raw = request.headers.get('x-openreader-test-namespace')?.trim(); + if (!raw) return AUDIOBOOKS_V1_DIR; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; +} export async function GET(request: NextRequest) { try { @@ -11,54 +21,49 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - const docstoreDir = join(process.cwd(), 'docstore'); - const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + if (!(await isAudiobooksV1Ready())) { + return NextResponse.json( + { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { - return NextResponse.json({ chapters: [], exists: false }); + return NextResponse.json({ + chapters: [], + exists: false, + hasComplete: false, + bookId: null, + settings: null, + }); } - // Read all chapter metadata - const files = await readdir(intermediateDir); - const metaFiles = files.filter(f => f.endsWith('.meta.json')); - const chapters: Array<{ - index: number; - title: string; - duration?: number; - status: 'completed' | 'error'; - bookId: string; - format?: TTSAudiobookFormat; - }> = []; - - for (const metaFile of metaFiles) { - try { - const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8')); - chapters.push({ - index: meta.index, - title: meta.title, - duration: meta.duration, - status: 'completed', - bookId, - format: meta.format || 'm4b' - }); - } catch (error) { - console.error(`Error reading metadata file ${metaFile}:`, error); - } + const stored = await listStoredChapters(intermediateDir, request.signal); + const chapters: TTSAudiobookChapter[] = stored.map((chapter) => ({ + index: chapter.index, + title: chapter.title, + duration: chapter.durationSec, + status: 'completed', + bookId, + format: chapter.format as TTSAudiobookFormat, + })); + + let settings: AudiobookGenerationSettings | null = null; + try { + settings = JSON.parse(await readFile(join(intermediateDir, 'audiobook.meta.json'), 'utf8')) as AudiobookGenerationSettings; + } catch { + settings = null; } - // Sort chapters by index - chapters.sort((a, b) => a.index - b.index); - - // Check if complete audiobook exists (either format) - const format = chapters[0]?.format || 'm4b'; - const completePath = join(intermediateDir, `complete.${format}`); - const hasComplete = existsSync(completePath); + const hasComplete = existsSync(join(intermediateDir, 'complete.mp3')) || existsSync(join(intermediateDir, 'complete.m4b')); return NextResponse.json({ chapters, exists: true, hasComplete, - bookId + bookId, + settings, }); } catch (error) { diff --git a/src/app/api/documents/library/content/route.ts b/src/app/api/documents/library/content/route.ts new file mode 100644 index 0000000..721779e --- /dev/null +++ b/src/app/api/documents/library/content/route.ts @@ -0,0 +1,104 @@ +import { readFile, stat } from 'fs/promises'; +import path from 'path'; +import { NextRequest, NextResponse } from 'next/server'; +import { contentTypeForName, decodeLibraryId, isPathWithinRoot, parseLibraryRoots } from '@/lib/server/library'; + +export const dynamic = 'force-dynamic'; + +const HEADER_CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F]/g; + +function stripHeaderControlChars(value: string): string { + return value.replace(HEADER_CONTROL_CHARS_REGEX, ''); +} + +function toAsciiFilenameFallback(filename: string): string { + const stripped = stripHeaderControlChars(filename); + const withoutQuotes = stripped.replace(/["\\]/g, ''); + const asciiOnly = withoutQuotes.replace(/[^\x20-\x7E]/g, '_').replace(/[\/\\]/g, '_').trim(); + return asciiOnly || 'download'; +} + +function encodeRFC5987ValueChars(value: string): string { + const stripped = stripHeaderControlChars(value); + const bytes = new TextEncoder().encode(stripped); + let out = ''; + + for (const byte of bytes) { + const isAlphaNumeric = + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a); + + const isAttrChar = + isAlphaNumeric || + byte === 0x21 || + byte === 0x23 || + byte === 0x24 || + byte === 0x26 || + byte === 0x2b || + byte === 0x2d || + byte === 0x2e || + byte === 0x5e || + byte === 0x5f || + byte === 0x60 || + byte === 0x7c || + byte === 0x7e; + + out += isAttrChar ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + + return out || 'download'; +} + +function contentDispositionAttachment(filename: string): string { + const fallback = toAsciiFilenameFallback(filename); + const encoded = encodeRFC5987ValueChars(filename); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const id = url.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + const decoded = decodeLibraryId(id); + if (!decoded) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const roots = parseLibraryRoots(); + const root = roots[decoded.rootIndex]; + if (!root) { + return NextResponse.json({ error: 'Invalid library root' }, { status: 400 }); + } + + const resolvedRoot = path.resolve(root); + const resolvedFile = path.resolve(resolvedRoot, decoded.relativePath); + if (!isPathWithinRoot(resolvedRoot, resolvedFile)) { + return NextResponse.json({ error: 'Invalid path' }, { status: 400 }); + } + + let fileStat: Awaited<ReturnType<typeof stat>>; + try { + fileStat = await stat(resolvedFile); + } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (!fileStat.isFile()) { + return NextResponse.json({ error: 'Not a file' }, { status: 400 }); + } + + const content = await readFile(resolvedFile); + const fileName = path.basename(decoded.relativePath); + + return new NextResponse(content, { + headers: { + 'Content-Type': contentTypeForName(fileName), + 'Content-Disposition': contentDispositionAttachment(fileName), + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/app/api/documents/library/route.ts b/src/app/api/documents/library/route.ts new file mode 100644 index 0000000..a4cb0d0 --- /dev/null +++ b/src/app/api/documents/library/route.ts @@ -0,0 +1,128 @@ +import type { Dirent } from 'fs'; +import { readdir, stat } from 'fs/promises'; +import path from 'path'; +import { NextRequest, NextResponse } from 'next/server'; +import { parseLibraryRoots } from '@/lib/server/library'; +import type { DocumentType } from '@/types/documents'; + +export const dynamic = 'force-dynamic'; + +type LibraryDocument = { + id: string; + name: string; + size: number; + lastModified: number; + type: DocumentType; +}; + +const SUPPORTED_EXTENSIONS = new Set([ + '.pdf', + '.epub', + '.html', + '.htm', + '.txt', + '.md', + '.mdown', + '.markdown', +]); + +const IGNORE_DIR_NAMES = new Set(['.git', 'node_modules', 'model', 'tmp', 'documents', 'documents_v1', 'audiobooks_v1']); + +function toPosixPath(filePath: string): string { + return filePath.split(path.sep).join('/'); +} + +function libraryDocumentTypeFromName(name: string): DocumentType { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'pdf'; + if (ext === '.epub') return 'epub'; + return 'html'; +} + +let cache: + | { + cacheKey: string; + cachedAt: number; + documents: LibraryDocument[]; + } + | undefined; + +async function scanLibraryRoot(root: string, rootIndex: number, limit: number): Promise<LibraryDocument[]> { + const results: LibraryDocument[] = []; + const resolvedRoot = path.resolve(root); + + async function walk(currentDir: string): Promise<void> { + if (results.length >= limit) return; + + let entries: Dirent[]; + try { + entries = await readdir(currentDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (results.length >= limit) return; + + if (entry.isDirectory()) { + if (entry.name.startsWith('.')) continue; + if (IGNORE_DIR_NAMES.has(entry.name)) continue; + if (entry.name.endsWith('-audiobook')) continue; + await walk(path.join(currentDir, entry.name)); + continue; + } + + if (!entry.isFile()) continue; + + const ext = path.extname(entry.name).toLowerCase(); + if (!SUPPORTED_EXTENSIONS.has(ext)) continue; + + const fullPath = path.join(currentDir, entry.name); + let fileStat: Awaited<ReturnType<typeof stat>>; + try { + fileStat = await stat(fullPath); + } catch { + continue; + } + + const relativePath = toPosixPath(path.relative(resolvedRoot, fullPath)); + const id = Buffer.from(`${rootIndex}:${relativePath}`, 'utf8').toString('base64url'); + + results.push({ + id, + name: relativePath, + size: fileStat.size, + lastModified: fileStat.mtimeMs, + type: libraryDocumentTypeFromName(relativePath), + }); + } + } + + await walk(resolvedRoot); + return results; +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const refresh = url.searchParams.get('refresh') === '1'; + const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit') ?? '5000'), 10000)); + + const roots = parseLibraryRoots(); + const cacheKey = `${roots.join('|')}::${limit}`; + + if (!refresh && cache && cache.cacheKey === cacheKey && Date.now() - cache.cachedAt < 30_000) { + return NextResponse.json({ documents: cache.documents }); + } + + const documents: LibraryDocument[] = []; + for (let i = 0; i < roots.length; i++) { + const rootDocs = await scanLibraryRoot(roots[i], i, Math.max(0, limit - documents.length)); + documents.push(...rootDocs); + if (documents.length >= limit) break; + } + + documents.sort((a, b) => a.name.localeCompare(b.name)); + + cache = { cacheKey, cachedAt: Date.now(), documents }; + return NextResponse.json({ documents }); +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index f926e2f..c822058 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,82 +1,162 @@ -import { writeFile, readFile, readdir, mkdir, unlink } from 'fs/promises'; +import { createHash } from 'crypto'; +import { readdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; -import type { BaseDocument, SyncedDocument } from '@/types/documents'; +import { DOCUMENTS_V1_DIR, isDocumentsV1Ready } from '@/lib/server/docstore'; +import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents'; -const DOCS_DIR = path.join(process.cwd(), 'docstore'); +const SYNC_DIR = DOCUMENTS_V1_DIR; + +async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise<void> { + if (!Number.isFinite(lastModifiedMs)) return; + const mtime = new Date(lastModifiedMs); + if (Number.isNaN(mtime.getTime())) return; -// Ensure documents directory exists -async function ensureDocsDir() { try { - await mkdir(DOCS_DIR, { recursive: true }); + await utimes(filePath, mtime, mtime); } catch (error) { - console.error('Error creating documents directory:', error); + console.warn('Failed to set document mtime:', filePath, error); } } +function toDocumentTypeFromName(name: string): DocumentType { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'pdf'; + if (ext === '.epub') return 'epub'; + if (ext === '.docx') return 'docx'; + return 'html'; +} + +function parseSyncedFileName(fileName: string): { id: string; name: string } | null { + const match = /^([a-f0-9]{64})__(.+)$/i.exec(fileName); + if (!match) return null; + try { + return { id: match[1].toLowerCase(), name: decodeURIComponent(match[2]) }; + } catch { + return null; + } +} + +async function loadSyncedDocuments(includeData: boolean): Promise<(BaseDocument | SyncedDocument)[]> { + const results: (BaseDocument | SyncedDocument)[] = []; + let files: string[] = []; + + try { + files = await readdir(SYNC_DIR); + } catch { + return results; + } + + for (const file of files) { + const parsed = parseSyncedFileName(file); + if (!parsed) continue; + + const filePath = path.join(SYNC_DIR, file); + let fileStat: Awaited<ReturnType<typeof stat>>; + try { + fileStat = await stat(filePath); + } catch { + continue; + } + + if (!fileStat.isFile()) continue; + + const type = toDocumentTypeFromName(parsed.name); + const metadata: BaseDocument = { + id: parsed.id, + name: parsed.name, + size: fileStat.size, + lastModified: fileStat.mtimeMs, + type, + }; + + if (!includeData) { + results.push(metadata); + continue; + } + + const content = await readFile(filePath); + results.push({ + ...metadata, + data: Array.from(new Uint8Array(content)), + }); + } + + return results; +} + export async function POST(req: NextRequest) { try { - await ensureDocsDir(); + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } const data = await req.json(); const documents = data.documents as SyncedDocument[]; - - // Save document metadata and content - for (const doc of documents) { - const docPath = path.join(DOCS_DIR, `${doc.id}.json`); - const contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`); - - // Save metadata (excluding binary data) - const metadata = { - id: doc.id, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - type: doc.type - }; - - await writeFile(docPath, JSON.stringify(metadata)); - // Save content as raw binary file with proper handling for both PDF and EPUB - const content = Buffer.from(new Uint8Array(doc.data)); - await writeFile(contentPath, content); + let existingFiles: string[] = []; + try { + existingFiles = await readdir(SYNC_DIR); + } catch { + existingFiles = []; } - return NextResponse.json({ success: true }); + const existingById = new Map<string, string>(); + for (const file of existingFiles) { + const parsed = parseSyncedFileName(file); + if (!parsed) continue; + if (!existingById.has(parsed.id)) { + existingById.set(parsed.id, file); + } + } + + const stored: Array<{ oldId: string; id: string; name: string }> = []; + + for (const doc of documents) { + const content = Buffer.from(new Uint8Array(doc.data)); + const id = createHash('sha256').update(content).digest('hex'); + + const baseName = path.basename(doc.name || `${id}.${doc.type}`); + const safeName = baseName.replaceAll('\u0000', '').slice(0, 240) || `${id}.${doc.type}`; + + const existingFile = existingById.get(id); + const targetFileName = existingFile ?? `${id}__${encodeURIComponent(safeName)}`; + const targetPath = path.join(SYNC_DIR, targetFileName); + + if (!existingFile) { + await writeFile(targetPath, content); + existingById.set(id, targetFileName); + } + + await trySetFileMtime(targetPath, doc.lastModified); + + stored.push({ oldId: doc.id, id, name: safeName }); + } + + return NextResponse.json({ success: true, stored }); } catch (error) { console.error('Error saving documents:', error); return NextResponse.json({ error: 'Failed to save documents' }, { status: 500 }); } } -export async function GET() { +export async function GET(req: NextRequest) { try { - await ensureDocsDir(); - const documents: SyncedDocument[] = []; - - const files = await readdir(DOCS_DIR); - const jsonFiles = files.filter(file => file.endsWith('.json')); - - for (const file of jsonFiles) { - const docPath = path.join(DOCS_DIR, file); - - try { - const metadata = JSON.parse(await readFile(docPath, 'utf8')) as BaseDocument; - const contentPath = path.join(DOCS_DIR, `${metadata.id}.${metadata.type}`); - const content = await readFile(contentPath); - - // Ensure consistent array format for both PDF and EPUB - const uint8Array = new Uint8Array(content); - - documents.push({ - ...metadata, - data: Array.from(uint8Array) - }); - } catch (error) { - console.error(`Error processing file ${file}:`, error); - continue; - } + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); } + const url = new URL(req.url); + const format = url.searchParams.get('format') ?? 'sync'; + const includeData = format !== 'metadata'; + + const documents = await loadSyncedDocuments(includeData); + return NextResponse.json({ documents }); } catch (error) { console.error('Error loading documents:', error); @@ -86,12 +166,31 @@ export async function GET() { export async function DELETE() { try { - await ensureDocsDir(); - const files = await readdir(DOCS_DIR); - - for (const file of files) { - const filePath = path.join(DOCS_DIR, file); - await unlink(filePath); + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + + // Delete synced docs (new format) safely without touching unrelated docstore data. + let syncFiles: string[] = []; + try { + syncFiles = await readdir(SYNC_DIR); + } catch { + syncFiles = []; + } + + for (const file of syncFiles) { + const filePath = path.join(SYNC_DIR, file); + try { + const st = await stat(filePath); + if (st.isFile()) { + await unlink(filePath); + } + } catch { + continue; + } } return NextResponse.json({ success: true }); diff --git a/src/app/api/migrations/v1/route.ts b/src/app/api/migrations/v1/route.ts new file mode 100644 index 0000000..0ea512a --- /dev/null +++ b/src/app/api/migrations/v1/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { existsSync } from 'fs'; +import { mkdir, readdir, rename, rm } from 'fs/promises'; +import { join } from 'path'; +import { + AUDIOBOOKS_V1_DIR, + ensureAudiobooksV1Ready, + ensureDocumentsV1Ready, + isAudiobooksV1Ready, + isDocumentsV1Ready, +} from '@/lib/server/docstore'; + +type Mapping = { oldId: string; id: string }; + +function isSafeId(value: string): boolean { + return /^[a-zA-Z0-9._-]{1,128}$/.test(value); +} + +async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { + let moved = 0; + let skipped = 0; + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(sourceDir, { withFileTypes: true }); + } catch { + return { moved, skipped }; + } + + for (const entry of entries) { + const sourcePath = join(sourceDir, entry.name); + const targetPath = join(targetDir, entry.name); + + if (entry.isDirectory()) { + await mkdir(targetPath, { recursive: true }); + const nested = await mergeDirectoryContents(sourcePath, targetPath); + moved += nested.moved; + skipped += nested.skipped; + + try { + const remaining = await readdir(sourcePath); + if (remaining.length === 0) { + await rm(sourcePath); + } + } catch {} + continue; + } + + if (!entry.isFile()) continue; + + if (existsSync(targetPath)) { + skipped++; + continue; + } + + try { + await rename(sourcePath, targetPath); + moved++; + } catch { + skipped++; + } + } + + return { moved, skipped }; +} + +async function rekeyAudiobooksV1(mappings: Mapping[]): Promise<{ renamed: number; merged: number; skipped: number }> { + let renamed = 0; + let merged = 0; + let skipped = 0; + + for (const mapping of mappings) { + if (mapping.oldId === mapping.id) continue; + const sourceDir = join(AUDIOBOOKS_V1_DIR, `${mapping.oldId}-audiobook`); + if (!existsSync(sourceDir)) continue; + + const targetDir = join(AUDIOBOOKS_V1_DIR, `${mapping.id}-audiobook`); + if (!existsSync(targetDir)) { + try { + await rename(sourceDir, targetDir); + renamed++; + continue; + } catch { + // Fall through to merge. + } + } + + await mkdir(targetDir, { recursive: true }); + const res = await mergeDirectoryContents(sourceDir, targetDir); + if (res.moved > 0) merged++; + skipped += res.skipped; + + try { + const remaining = await readdir(sourceDir); + if (remaining.length === 0) { + await rm(sourceDir); + } + } catch {} + } + + return { renamed, merged, skipped }; +} + +export async function POST(request: NextRequest) { + try { + const raw = (await request.json().catch(() => null)) as { mappings?: Mapping[] } | null; + const mappings = (raw?.mappings ?? []).filter( + (m): m is Mapping => Boolean(m && typeof m.oldId === 'string' && typeof m.id === 'string'), + ); + + for (const mapping of mappings) { + if (!isSafeId(mapping.oldId) || !isSafeId(mapping.id)) { + return NextResponse.json({ error: 'Invalid document id mapping' }, { status: 400 }); + } + } + + await ensureDocumentsV1Ready(); + await ensureAudiobooksV1Ready(); + const rekey = await rekeyAudiobooksV1(mappings); + + const documentsReady = await isDocumentsV1Ready(); + const audiobooksReady = await isAudiobooksV1Ready(); + + return NextResponse.json({ + success: true, + documentsReady, + audiobooksReady, + rekey, + }); + } catch (error) { + console.error('Error running v1 migrations:', error); + return NextResponse.json({ error: 'Failed to run v1 migrations' }, { status: 500 }); + } +} + diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 325b96c..8cccb88 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { useEPUB } from '@/contexts/EPUBContext'; @@ -14,12 +14,15 @@ import TTSPlayer from '@/components/player/TTSPlayer'; import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { DownloadIcon } from '@/components/icons/Icons'; -import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import { resolveDocumentId } from '@/lib/dexie'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; export default function EPUBPage() { const { id } = useParams(); + const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); const { stop } = useTTS(); const [error, setError] = useState<string | null>(null); @@ -33,20 +36,28 @@ export default function EPUBPage() { const loadDocument = useCallback(async () => { console.log('Loading new epub (from page.tsx)'); stop(); // Reset TTS when loading new document - + let didRedirect = false; try { if (!id) { setError('Document not found'); return; } - await setCurrentDocument(id as string); + const resolved = await resolveDocumentId(id as string); + if (resolved !== (id as string)) { + didRedirect = true; + router.replace(`/epub/${resolved}`); + return; + } + await setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - setIsLoading(false); + if (!didRedirect) { + setIsLoading(false); + } } - }, [id, setCurrentDocument, stop]); + }, [id, router, setCurrentDocument, stop]); useEffect(() => { if (!isLoading) return; @@ -88,18 +99,18 @@ export default function EPUBPage() { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => { - return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); }, [createEPUBAudioBook, id]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => { - return regenerateEPUBChapter(chapterIndex, bookId, format, signal); + return regenerateEPUBChapter(chapterIndex, bookId, settings.format, signal, settings); }, [regenerateEPUBChapter]); if (error) { diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index 8127fc0..a350b0f 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { useHTML } from '@/contexts/HTMLContext'; @@ -12,9 +12,11 @@ import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { ZoomControl } from '@/components/ZoomControl'; +import { resolveDocumentId } from '@/lib/dexie'; export default function HTMLPage() { const { id } = useParams(); + const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML(); const { stop } = useTTS(); const [error, setError] = useState<string | null>(null); @@ -28,19 +30,28 @@ export default function HTMLPage() { if (!isLoading) return; console.log('Loading new HTML document (from page.tsx)'); stop(); + let didRedirect = false; try { if (!id) { setError('Document not found'); return; } - await setCurrentDocument(id as string); + const resolved = await resolveDocumentId(id as string); + if (resolved !== (id as string)) { + didRedirect = true; + router.replace(`/html/${resolved}`); + return; + } + await setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - setIsLoading(false); + if (!didRedirect) { + setIsLoading(false); + } } - }, [isLoading, id, setCurrentDocument, stop]); + }, [isLoading, id, router, setCurrentDocument, stop]); useEffect(() => { loadDocument(); diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 276dc52..8af59a4 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -2,7 +2,7 @@ import dynamic from 'next/dynamic'; import { usePDF } from '@/contexts/PDFContext'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; @@ -12,8 +12,10 @@ import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons'; import { Header } from '@/components/Header'; import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; -import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; +import { resolveDocumentId } from '@/lib/dexie'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -28,6 +30,7 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const { id } = useParams(); + const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); const { stop } = useTTS(); const [error, setError] = useState<string | null>(null); @@ -41,19 +44,28 @@ export default function PDFViewerPage() { if (!isLoading) return; // Prevent calls when not loading new doc console.log('Loading new document (from page.tsx)'); stop(); // Reset TTS when loading new document + let didRedirect = false; try { if (!id) { setError('Document not found'); return; } - setCurrentDocument(id as string); + const resolved = await resolveDocumentId(id as string); + if (resolved !== (id as string)) { + didRedirect = true; + router.replace(`/pdf/${resolved}`); + return; + } + setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - setIsLoading(false); + if (!didRedirect) { + setIsLoading(false); + } } - }, [isLoading, id, setCurrentDocument, stop]); + }, [isLoading, id, router, setCurrentDocument, stop]); useEffect(() => { loadDocument(); @@ -82,18 +94,18 @@ export default function PDFViewerPage() { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => { - return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); }, [createPDFAudioBook, id]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => { - return regeneratePDFChapter(chapterIndex, bookId, format, signal); + return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); }, [regeneratePDFChapter]); if (error) { diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index e0cb4ed..f71cb40 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Fragment, useState, useRef, useCallback, useEffect } from 'react'; +import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -9,6 +9,8 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco import { ConfirmDialog } from '@/components/ConfirmDialog'; import { LoadingSpinner } from '@/components/Spinner'; import { useConfig } from '@/contexts/ConfigContext'; +import { useTTS } from '@/contexts/TTSContext'; +import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { getAudiobookStatus, @@ -17,6 +19,7 @@ import { downloadAudiobookChapter, downloadAudiobook } from '@/lib/client'; +import type { AudiobookGenerationSettings } from '@/types/client'; interface AudiobookExportModalProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; @@ -26,12 +29,12 @@ interface AudiobookExportModalProps { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => Promise<string>; // Returns bookId onRegenerateChapter?: ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => Promise<TTSAudiobookChapter>; } @@ -44,7 +47,8 @@ export function AudiobookExportModal({ onGenerateAudiobook, onRegenerateChapter }: AudiobookExportModalProps) { - const { isLoading, isDBReady } = useConfig(); + const { isLoading, isDBReady, ttsProvider, ttsModel, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig(); + const { availableVoices } = useTTS(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const [isGenerating, setIsGenerating] = useState(false); const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]); @@ -54,6 +58,10 @@ export function AudiobookExportModal({ const [isRefreshingChapters, setIsRefreshingChapters] = useState(false); const [currentChapter, setCurrentChapter] = useState<string>(''); const [format, setFormat] = useState<TTSAudiobookFormat>('m4b'); + const [audiobookVoice, setAudiobookVoice] = useState<string>(configVoice || ''); + const [nativeSpeed, setNativeSpeed] = useState<number>(voiceSpeed); + const [postSpeed, setPostSpeed] = useState<number>(audioPlayerSpeed); + const [savedSettings, setSavedSettings] = useState<AudiobookGenerationSettings | null>(null); const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null); const abortControllerRef = useRef<AbortController | null>(null); const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null); @@ -61,6 +69,35 @@ export function AudiobookExportModal({ const [errorMessage, setErrorMessage] = useState<string | null>(null); const [showRegenerateHint, setShowRegenerateHint] = useState(false); + const formatSpeed = useCallback((speed: number) => { + return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1); + }, []); + + const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0; + const isLegacyAudiobookMissingSettings = hasExistingAudiobook && savedSettings === null; + + useEffect(() => { + if (savedSettings) return; + if (audiobookVoice) return; + if (availableVoices.length > 0) { + setAudiobookVoice(availableVoices[0] || ''); + } + }, [savedSettings, audiobookVoice, availableVoices]); + + const effectiveSettings: AudiobookGenerationSettings | null = useMemo(() => { + if (savedSettings) return savedSettings; + const nextVoice = audiobookVoice || configVoice || availableVoices[0] || ''; + if (!nextVoice) return null; + return { + ttsProvider, + ttsModel, + voice: nextVoice, + nativeSpeed, + postSpeed, + format, + }; + }, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, nativeSpeed, postSpeed, format]); + const fetchExistingChapters = useCallback(async (soft: boolean = false) => { if (soft) { setIsRefreshingChapters(true); @@ -69,15 +106,22 @@ export function AudiobookExportModal({ } try { const data = await getAudiobookStatus(documentId); - if (data.exists && data.chapters.length > 0) { - setChapters(data.chapters); + if (data.exists) { + setChapters(data.chapters || []); setBookId(data.bookId); - // Set format from existing chapters - this ensures the format matches what was actually generated if (data.chapters[0]?.format) { const detectedFormat = data.chapters[0].format as TTSAudiobookFormat; setFormat(detectedFormat); } - // If we have a complete audiobook, we're done + if (data.settings) { + setSavedSettings(data.settings); + setAudiobookVoice(data.settings.voice); + setNativeSpeed(data.settings.nativeSpeed); + setPostSpeed(data.settings.postSpeed); + setFormat(data.settings.format); + } else { + setSavedSettings(null); + } if (data.hasComplete) { setProgress(100); } @@ -85,6 +129,7 @@ export function AudiobookExportModal({ // If nothing exists, clear chapters/bookId to reflect current state setChapters([]); setBookId(null); + setSavedSettings(null); } } catch (error) { console.error('Error fetching existing chapters:', error); @@ -116,6 +161,10 @@ export function AudiobookExportModal({ }, []); const handleStartGeneration = useCallback(async () => { + if (!effectiveSettings) { + setErrorMessage('No voice selected; please choose a voice before generating.'); + return; + } setIsGenerating(true); setProgress(0); setCurrentChapter(''); @@ -131,7 +180,7 @@ export function AudiobookExportModal({ (progress) => setProgress(progress), abortControllerRef.current.signal, handleChapterComplete, - format + effectiveSettings ); setBookId(generatedBookId); } catch (error) { @@ -141,7 +190,7 @@ export function AudiobookExportModal({ console.log('Audiobook generation cancelled gracefully'); } else { // Show error to user for actual errors - setErrorMessage('Failed to generate audiobook. Please try again.'); + setErrorMessage(error instanceof Error ? error.message : 'Failed to generate audiobook. Please try again.'); } } finally { setIsGenerating(false); @@ -152,7 +201,7 @@ export function AudiobookExportModal({ await fetchExistingChapters(true); } } - }, [onGenerateAudiobook, handleChapterComplete, setProgress, bookId, format, documentId, fetchExistingChapters]); + }, [onGenerateAudiobook, handleChapterComplete, setProgress, bookId, documentId, fetchExistingChapters, effectiveSettings]); const handleCancel = useCallback(() => { if (abortControllerRef.current) { @@ -181,6 +230,10 @@ export function AudiobookExportModal({ const handleRegenerateChapter = useCallback(async (chapter: TTSAudiobookChapter) => { if (!onRegenerateChapter || !bookId) return; + if (!effectiveSettings) { + setErrorMessage('No voice selected; please choose a voice before generating.'); + return; + } if (!showRegenerateHint) { setShowRegenerateHint(true); @@ -208,7 +261,7 @@ export function AudiobookExportModal({ const regeneratedChapter = await onRegenerateChapter( chapter.index, bookId, - format, + effectiveSettings, abortControllerRef.current.signal ); @@ -224,7 +277,7 @@ export function AudiobookExportModal({ if (error instanceof Error && error.message.includes('cancelled')) { console.log('Chapter regeneration cancelled'); } else { - setErrorMessage('Failed to regenerate chapter. Please try again.'); + setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate chapter. Please try again.'); // Mark as error setChapters(prev => prev.map(c => c.index === chapter.index @@ -240,7 +293,7 @@ export function AudiobookExportModal({ // Refresh chapters to get updated data (soft refresh list only) await fetchExistingChapters(true); } - }, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters, showRegenerateHint]); + }, [onRegenerateChapter, bookId, setProgress, fetchExistingChapters, showRegenerateHint, effectiveSettings]); const performDeleteChapter = useCallback(async () => { if (!bookId || !pendingDeleteChapter) return; @@ -358,6 +411,8 @@ export function AudiobookExportModal({ const hasAnyChapters = chapters.length > 0; const showResumeButton = !isGenerating && !regeneratingChapter && hasAnyChapters; const showResetButton = !isGenerating && !regeneratingChapter && hasAnyChapters; + const settingsLocked = savedSettings !== null; + const canGenerate = effectiveSettings !== null; // Do not render until storage/config is initialized if (isLoading || !isDBReady) { @@ -410,101 +465,203 @@ export function AudiobookExportModal({ <LoadingSpinner /> </div> ) : ( - <> - <div className="space-y-4"> - <div className="flex justify-between items-center gap-3"> - <h3 className="text-lg font-medium text-foreground">Export Audiobook</h3> - {!isGenerating && ( - <div className="flex items-center gap-2"> - {chapters.length === 0 && ( - <Listbox - value={format} - onChange={(newFormat) => setFormat(newFormat)} - disabled={chapters.length > 0} - > - <div className="relative"> - <ListboxButton className="relative cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[100px]"> - <span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> - <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> - <ChevronUpDownIcon className="h-5 w-5 text-muted" /> - </span> - </ListboxButton> - <Transition - as={Fragment} - leave="transition ease-in duration-100" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - <ListboxOptions className="absolute right-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10"> - <ListboxOption - value="m4b" - className={({ active }) => - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' - }` - } - > - {({ selected }) => ( - <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> - M4B - </span> - )} - </ListboxOption> - <ListboxOption - value="mp3" - className={({ active }) => - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' - }` - } - > - {({ selected }) => ( - <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> - MP3 - </span> - )} - </ListboxOption> - </ListboxOptions> - </Transition> - </div> - </Listbox> - )} - {chapters.length === 0 && ( - <Button - onClick={handleStartGeneration} - className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm - font-medium text-background hover:bg-secondary-accent focus:outline-none - focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 - transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" - > - Start Generation - </Button> - )} - {showResumeButton && ( - <Button - onClick={handleStartGeneration} - className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm - font-medium text-background hover:bg-secondary-accent focus:outline-none - focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 - transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" - > - Resume - </Button> - )} - {showResetButton && ( - <Button - onClick={() => setShowResetConfirm(true)} - disabled={isGenerating} - className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm - font-medium text-background hover:bg-red-500/90 focus:outline-none - focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 - transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" - title="Delete all generated chapters/pages for this document" - > - Reset - </Button> - )} - </div> - )} - </div> + <> + <div className="space-y-4"> + <div className="flex justify-between items-start gap-3"> + <h3 className="text-lg font-medium text-foreground">Export Audiobook</h3> + </div> + {!isGenerating && ( + <div className="flex justify-center"> + <div className="w-full rounded-xl border border-offbase bg-background p-4"> + <div className="flex items-start justify-between gap-3"> + <div> + <h4 className="text-sm font-medium text-foreground">Generation settings</h4> + <p className="text-xs text-muted mt-0.5"> + These settings are saved per audiobook for consistent resumes across devices. + </p> + </div> + {settingsLocked && ( + <span className="inline-flex items-center rounded-full bg-offbase px-2 py-0.5 text-xs text-muted"> + Locked + </span> + )} + </div> + + {isLegacyAudiobookMissingSettings && ( + <div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground"> + <div className="font-medium">Saved generation settings not found</div> + <div className="mt-1 text-muted"> + This audiobook was likely created before v1 metadata was introduced, so OpenReader can’t know + which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with + v1 metadata (so settings are saved for resumes across devices). + </div> + </div> + )} + + {settingsLocked && savedSettings ? ( + <div className="mt-3 text-sm"> + <div className="text-muted"> + Voice: <span className="text-foreground">{savedSettings.voice}</span> + </div> + <div className="text-muted"> + Native speed: <span className="text-foreground">{formatSpeed(savedSettings.nativeSpeed)}x</span> + </div> + <div className="text-muted"> + Post speed: <span className="text-foreground">{formatSpeed(savedSettings.postSpeed)}x</span> + </div> + <div className="text-muted"> + Format: <span className="text-foreground">{savedSettings.format.toUpperCase()}</span> + </div> + <p className="text-xs text-muted mt-2"> + Reset the audiobook to change generation settings. + </p> + </div> + ) : ( + <div className="mt-3 space-y-4"> + <div className="space-y-1"> + <div className="text-xs font-medium text-foreground">Voice</div> + <VoicesControlBase + availableVoices={availableVoices} + voice={audiobookVoice} + onChangeVoice={setAudiobookVoice} + ttsProvider={ttsProvider} + ttsModel={ttsModel} + /> + </div> + + <div className="space-y-1"> + <div className="text-xs font-medium text-foreground">Output format</div> + {chapters.length === 0 ? ( + <Listbox + value={format} + onChange={(newFormat) => setFormat(newFormat)} + disabled={chapters.length > 0 || settingsLocked} + > + <div className="relative inline-block"> + <ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[120px]"> + <span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> + <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> + <ChevronUpDownIcon className="h-5 w-5 text-muted" /> + </span> + </ListboxButton> + <Transition + as={Fragment} + leave="transition ease-in duration-100" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10"> + <ListboxOption + value="m4b" + className={({ active }) => + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> + M4B + </span> + )} + </ListboxOption> + <ListboxOption + value="mp3" + className={({ active }) => + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> + MP3 + </span> + )} + </ListboxOption> + </ListboxOptions> + </Transition> + </div> + </Listbox> + ) : ( + <div className="text-sm text-foreground">{format.toUpperCase()}</div> + )} + </div> + + <div className="space-y-1"> + <div className="flex items-center"> + <div className="text-xs font-medium text-foreground mr-1">Native model speed</div> + <div className="text-xs text-muted">• {formatSpeed(nativeSpeed)}x</div> + </div> + <input + type="range" + min="0.5" + max="3" + step="0.1" + value={nativeSpeed} + onChange={(e) => setNativeSpeed(parseFloat(e.target.value))} + className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" + /> + </div> + + <div className="space-y-1"> + <div className="flex items-center"> + <div className="text-xs font-medium text-foreground mr-1">Post-generation speed</div> + <div className="text-xs text-muted">• {formatSpeed(postSpeed)}x</div> + </div> + <input + type="range" + min="0.5" + max="3" + step="0.1" + value={postSpeed} + onChange={(e) => setPostSpeed(parseFloat(e.target.value))} + className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" + /> + </div> + </div> + )} + + <div className="mt-4 space-y-2"> + {chapters.length === 0 && ( + <Button + onClick={handleStartGeneration} + disabled={!canGenerate} + className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm + font-medium text-background hover:bg-secondary-accent focus:outline-none + focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100" + > + Start Generation + </Button> + )} + {showResumeButton && ( + <Button + onClick={handleStartGeneration} + disabled={!canGenerate} + className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm + font-medium text-background hover:bg-secondary-accent focus:outline-none + focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100" + > + Resume + </Button> + )} + {showResetButton && ( + <Button + onClick={() => setShowResetConfirm(true)} + disabled={isGenerating} + className="w-full justify-center rounded-lg bg-red-500 px-3 py-2 text-sm + font-medium text-background hover:bg-red-500/90 focus:outline-none + focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.01]" + title="Delete all generated chapters/pages for this document" + > + Reset + </Button> + )} + </div> + </div> + </div> + )} {showRegenerateHint && ( <div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm"> <p className="text-xs sm:text-sm text-foreground"> @@ -681,11 +838,11 @@ export function AudiobookExportModal({ )} {chapters.length === 0 && !isGenerating && !isLoadingExisting && ( - <div className="text-center py-8"> + <div className="text-center"> <p className="text-sm text-muted"> - Generation will use current TTS playback options. - <br /> - Individual chapters will appear here as they are generated. + Audiobook settings are fixed after generation. Chapters will appear here as they are ready. + <br></br> + You can close this dialog while the audiobook is being generated. But returning to the home screen will cancel the generation. </p> </div> )} diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 515d300..3434417 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -8,7 +8,8 @@ import { useEPUB } from '@/contexts/EPUBContext'; import { usePDF } from '@/contexts/PDFContext'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { useParams } from 'next/navigation'; -import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; +import type { AudiobookGenerationSettings } from '@/types/client'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -82,25 +83,25 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, - format: TTSAudiobookFormat + settings: AudiobookGenerationSettings ) => { if (epub) { - return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); } else { - return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format); + return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); } }, [epub, createEPUBAudioBook, createPDFAudioBook, id]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, - format: TTSAudiobookFormat, + settings: AudiobookGenerationSettings, signal: AbortSignal ) => { if (epub) { - return regenerateEPUBChapter(chapterIndex, bookId, format, signal); + return regenerateEPUBChapter(chapterIndex, bookId, settings.format, signal, settings); } else { - return regeneratePDFChapter(chapterIndex, bookId, format, signal); + return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); } }, [epub, regenerateEPUBChapter, regeneratePDFChapter]); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index f319e2e..904d843 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -91,6 +91,7 @@ export function Footer() { -e API_BASE=http://kokoro-tts:8880/v1 \\ -p 3003:3003 \\ -v openreader_docstore:/app/docstore \\ +-v /path/to/your/library:/app/docstore/library:ro \\ ghcr.io/richardr1126/openreader-webui:latest` } </CodeBlock> diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index 4f330d2..8bab973 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -2,7 +2,7 @@ interface ProgressCardProps { progress: number; estimatedTimeRemaining?: string; onCancel: (e?: React.MouseEvent) => void; - operationType?: 'sync' | 'load' | 'audiobook'; + operationType?: 'sync' | 'load' | 'library' | 'audiobook'; cancelText?: string; currentChapter?: string; completedChapters?: number; @@ -22,6 +22,7 @@ export function ProgressCard({ const getOperationLabel = () => { if (operationType === 'sync') return 'Saving to Server'; if (operationType === 'load') return 'Loading from Server'; + if (operationType === 'library') return 'Importing Library'; if (operationType === 'audiobook') return 'Generating Audiobook'; return null; }; diff --git a/src/components/ProgressPopup.tsx b/src/components/ProgressPopup.tsx index 7f37269..4e0c7db 100644 --- a/src/components/ProgressPopup.tsx +++ b/src/components/ProgressPopup.tsx @@ -8,7 +8,7 @@ interface ProgressPopupProps { estimatedTimeRemaining?: string; onCancel: () => void; statusMessage?: string; - operationType?: 'sync' | 'load' | 'audiobook'; + operationType?: 'sync' | 'load' | 'library' | 'audiobook'; cancelText?: string; onClick?: () => void; currentChapter?: string; @@ -65,4 +65,4 @@ export function ProgressPopup({ </div> </Transition> ); -} \ No newline at end of file +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index ea5a770..cbbba06 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,7 +22,7 @@ import { import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/lib/dexie'; +import { syncDocumentsToServer, loadDocumentsFromServer, importDocumentsFromLibrary, getFirstVisit, setFirstVisit } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -51,9 +51,10 @@ export function SettingsModal() { const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isImportingLibrary, setIsImportingLibrary] = useState(false); const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); - const [operationType, setOperationType] = useState<'sync' | 'load'>('sync'); + const [operationType, setOperationType] = useState<'sync' | 'load' | 'library'>('sync'); const [abortController, setAbortController] = useState<AbortController | null>(null); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); @@ -214,6 +215,38 @@ export function SettingsModal() { } }; + const handleImportLibrary = async () => { + const controller = new AbortController(); + setAbortController(controller); + + try { + setIsImportingLibrary(true); + setShowProgress(true); + setProgress(0); + setOperationType('library'); + setStatusMessage('Scanning server library...'); + await importDocumentsFromLibrary((progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } catch (error) { + if (controller.signal.aborted) { + console.log('Library import cancelled'); + setStatusMessage('Operation cancelled'); + } else { + console.error('Library import failed:', error); + setStatusMessage('Library import failed. Please try again.'); + } + } finally { + setIsImportingLibrary(false); + setShowProgress(false); + setProgress(0); + setStatusMessage(''); + setAbortController(null); + } + }; + const handleClearLocal = async () => { await clearPDFs(); await clearEPUBs(); @@ -598,12 +631,29 @@ export function SettingsModal() { </TabPanel> <TabPanel className="space-y-4"> + <div className="space-y-1"> + <label className="block text-sm font-medium text-foreground">Server Library Import</label> + <div className="flex gap-2"> + <Button + onClick={handleImportLibrary} + disabled={isSyncing || isLoading || isImportingLibrary} + className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm + font-medium text-foreground hover:bg-offbase focus:outline-none + focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 + transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent + disabled:opacity-50" + > + {isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'} + </Button> + </div> + </div> + {isDev && <div className="space-y-1"> <label className="block text-sm font-medium text-foreground">Server Document Sync</label> <div className="flex gap-2"> <Button onClick={handleLoad} - disabled={isSyncing || isLoading} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 @@ -614,7 +664,7 @@ export function SettingsModal() { </Button> <Button onClick={handleSync} - disabled={isSyncing || isLoading} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 @@ -631,6 +681,7 @@ export function SettingsModal() { <div className="flex gap-2"> <Button onClick={() => setShowClearLocalConfirm(true)} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm font-medium text-background hover:bg-red-500/90 focus:outline-none focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 @@ -640,6 +691,7 @@ export function SettingsModal() { </Button> {isDev && <Button onClick={() => setShowClearServerConfirm(true)} + disabled={isSyncing || isLoading || isImportingLibrary} className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm font-medium text-background hover:bg-red-500/90 focus:outline-none focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 @@ -691,6 +743,7 @@ export function SettingsModal() { setProgress(0); setIsSyncing(false); setIsLoading(false); + setIsImportingLibrary(false); setStatusMessage(''); setOperationType('sync'); setAbortController(null); diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index c6691d1..5bc8eed 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -1,7 +1,7 @@ import { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { db } from '@/lib/dexie'; +import { getEpubDocument, getHtmlDocument, getPdfDocument } from '@/lib/dexie'; import { extractEpubCoverToDataUrl, extractRawTextSnippet, @@ -75,44 +75,44 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { let cancelled = false; - const run = async () => { - setIsGenerating(true); - try { - const targetWidth = 240; + const run = async () => { + setIsGenerating(true); + try { + const targetWidth = 240; - if (doc.type === 'pdf') { - const pdfDoc = await db['pdf-documents'].get(doc.id); - if (!pdfDoc?.data) return; - const dataUrl = await renderPdfFirstPageToDataUrl(pdfDoc.data, targetWidth); - if (cancelled) return; - imagePreviewCache.set(previewKey, dataUrl); - setImagePreview(dataUrl); - setTextPreview(null); - return; - } + if (doc.type === 'pdf') { + const pdfDoc = await getPdfDocument(doc.id); + if (!pdfDoc?.data) return; + const dataUrl = await renderPdfFirstPageToDataUrl(pdfDoc.data, targetWidth); + if (cancelled) return; + imagePreviewCache.set(previewKey, dataUrl); + setImagePreview(dataUrl); + setTextPreview(null); + return; + } - if (doc.type === 'epub') { - const epubDoc = await db['epub-documents'].get(doc.id); - if (!epubDoc?.data) return; - const cover = await extractEpubCoverToDataUrl(epubDoc.data, targetWidth); - if (cancelled) return; - if (cover) { - imagePreviewCache.set(previewKey, cover); - setImagePreview(cover); - setTextPreview(null); - } - return; - } + if (doc.type === 'epub') { + const epubDoc = await getEpubDocument(doc.id); + if (!epubDoc?.data) return; + const cover = await extractEpubCoverToDataUrl(epubDoc.data, targetWidth); + if (cancelled) return; + if (cover) { + imagePreviewCache.set(previewKey, cover); + setImagePreview(cover); + setTextPreview(null); + } + return; + } - if (doc.type === 'html') { - const htmlDoc = await db['html-documents'].get(doc.id); - if (cancelled) return; - const snippet = extractRawTextSnippet(htmlDoc?.data ?? ''); - textPreviewCache.set(previewKey, snippet); - setTextPreview(snippet); - setImagePreview(null); - return; - } + if (doc.type === 'html') { + const htmlDoc = await getHtmlDocument(doc.id); + if (cancelled) return; + const snippet = extractRawTextSnippet(htmlDoc?.data ?? ''); + textPreviewCache.set(previewKey, snippet); + setTextPreview(snippet); + setImagePreview(null); + return; + } } catch { // fall back to icon } finally { diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx index 0463977..eafe41a 100644 --- a/src/components/player/SpeedControl.tsx +++ b/src/components/player/SpeedControl.tsx @@ -3,7 +3,7 @@ import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons'; import { useConfig } from '@/contexts/ConfigContext'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; export const SpeedControl = ({ setSpeedAndRestart, @@ -13,10 +13,10 @@ export const SpeedControl = ({ setAudioPlayerSpeedAndRestart: (speed: number) => void; }) => { const { voiceSpeed, audioPlayerSpeed } = useConfig(); + const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed); const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed); - // Sync local speeds with global state useEffect(() => { setLocalVoiceSpeed(voiceSpeed); }, [voiceSpeed]); @@ -25,32 +25,31 @@ export const SpeedControl = ({ setLocalAudioSpeed(audioPlayerSpeed); }, [audioPlayerSpeed]); - // Handler for voice speed slider change (updates local state only) const handleVoiceSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { setLocalVoiceSpeed(parseFloat(event.target.value)); }, []); - // Handler for audio player speed slider change (updates local state only) const handleAudioSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { setLocalAudioSpeed(parseFloat(event.target.value)); }, []); - // Handler for voice speed slider release const handleVoiceSpeedChangeComplete = useCallback(() => { if (localVoiceSpeed !== voiceSpeed) { setSpeedAndRestart(localVoiceSpeed); } }, [localVoiceSpeed, voiceSpeed, setSpeedAndRestart]); - // Handler for audio player speed slider release const handleAudioSpeedChangeComplete = useCallback(() => { if (localAudioSpeed !== audioPlayerSpeed) { setAudioPlayerSpeedAndRestart(localAudioSpeed); } }, [localAudioSpeed, audioPlayerSpeed, setAudioPlayerSpeedAndRestart]); - // Display the currently active speed (prioritize audio player speed if different from 1.0) - const displaySpeed = localAudioSpeed !== 1.0 ? localAudioSpeed : localVoiceSpeed; + const displaySpeed = useMemo(() => (localAudioSpeed !== 1.0 ? localAudioSpeed : localVoiceSpeed), [localAudioSpeed, localVoiceSpeed]); + + const min = 0.5; + const max = 3; + const step = 0.1; return ( <Popover className="relative"> @@ -61,19 +60,20 @@ export const SpeedControl = ({ </PopoverButton> <PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase"> <div className="flex flex-col space-y-4"> - {/* Native Model Speed */} <div className="flex flex-col space-y-2"> <div className="text-xs font-medium text-foreground">Native model speed</div> <div className="flex justify-between"> - <span className="text-xs">0.5x</span> - <span className="text-xs font-bold">{Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x</span> - <span className="text-xs">3x</span> + <span className="text-xs">{min.toFixed(1)}x</span> + <span className="text-xs font-bold"> + {Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x + </span> + <span className="text-xs">{max.toFixed(1)}x</span> </div> <Input type="range" - min="0.5" - max="3" - step="0.1" + min={min} + max={max} + step={step} value={localVoiceSpeed} onChange={handleVoiceSpeedChange} onMouseUp={handleVoiceSpeedChangeComplete} @@ -83,19 +83,20 @@ export const SpeedControl = ({ /> </div> - {/* Audio Player Speed */} <div className="flex flex-col space-y-2"> <div className="text-xs font-medium text-foreground">Audio player speed</div> <div className="flex justify-between"> - <span className="text-xs">0.5x</span> - <span className="text-xs font-bold">{Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x</span> - <span className="text-xs">3x</span> + <span className="text-xs">{min.toFixed(1)}x</span> + <span className="text-xs font-bold"> + {Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x + </span> + <span className="text-xs">{max.toFixed(1)}x</span> </div> <Input type="range" - min="0.5" - max="3" - step="0.1" + min={min} + max={max} + step={step} value={localAudioSpeed} onChange={handleAudioSpeedChange} onMouseUp={handleAudioSpeedChangeComplete} @@ -108,4 +109,4 @@ export const SpeedControl = ({ </PopoverPanel> </Popover> ); -}; \ No newline at end of file +}; diff --git a/src/components/player/VoicesControl.tsx b/src/components/player/VoicesControl.tsx index 38d80f8..d854598 100644 --- a/src/components/player/VoicesControl.tsx +++ b/src/components/player/VoicesControl.tsx @@ -1,131 +1,23 @@ 'use client'; -import { - Listbox, - ListboxButton, - ListboxOption, - ListboxOptions, -} from '@headlessui/react'; -import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons'; import { useConfig } from '@/contexts/ConfigContext'; -import { useEffect, useMemo, useState } from 'react'; -import { parseKokoroVoiceNames, buildKokoroVoiceString, isKokoroModel, getMaxVoicesForProvider } from '@/utils/voice'; +import { useCallback } from 'react'; +import { VoicesControlBase } from '@/components/player/VoicesControlBase'; export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: { availableVoices: string[]; setVoiceAndRestart: (voice: string) => void; }) => { - const { voice: configVoice, ttsModel, ttsProvider } = useConfig(); - - const isKokoro = isKokoroModel(ttsModel); - const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel); - - // Local selection state for Kokoro multi-select - const [selectedVoices, setSelectedVoices] = useState<string[]>([]); - - useEffect(() => { - if (!(isKokoro && maxVoices > 1)) return; - let initial: string[] = []; - if (configVoice && configVoice.includes('+')) { - initial = parseKokoroVoiceNames(configVoice); - } else if (configVoice && availableVoices.includes(configVoice)) { - initial = [configVoice]; - } else if (availableVoices.length > 0) { - initial = [availableVoices[0]]; - } - // Clamp to provider limit - if (initial.length > maxVoices) { - initial = initial.slice(0, maxVoices); - } - setSelectedVoices(initial); - }, [isKokoro, maxVoices, configVoice, availableVoices]); - - // If the saved voice is not in the available list, use the first available voice (non-Kokoro or Kokoro limited) - const currentVoice = useMemo(() => { - if (isKokoro && maxVoices > 1) { - const combined = buildKokoroVoiceString(selectedVoices); - return combined || (availableVoices[0] || ''); - } - return (configVoice && availableVoices.includes(configVoice)) - ? configVoice - : availableVoices[0] || ''; - }, [isKokoro, maxVoices, selectedVoices, availableVoices, configVoice]); + const { voice, ttsModel, ttsProvider } = useConfig(); + const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]); return ( - <div className="relative"> - {(isKokoro && maxVoices > 1) ? ( - <Listbox - multiple - value={selectedVoices} - onChange={(vals: string[]) => { - if (!vals || vals.length === 0) return; // prevent empty selection - - let next = vals; - - // Enforce provider max selection - if (vals.length > maxVoices) { - const newlyAdded = vals.find(v => !selectedVoices.includes(v)); - if (newlyAdded) { - const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? ''; - const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean); - next = pair.slice(0, maxVoices); - } else { - next = vals.slice(-maxVoices); - } - } - - setSelectedVoices(next); - const combined = buildKokoroVoiceString(next); - if (combined) { - setVoiceAndRestart(combined); - } - }} - > - <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> - <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> - <span> - {selectedVoices.length > 1 - ? selectedVoices.join(' + ') - : selectedVoices[0] || currentVoice} - </span> - <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> - </ListboxButton> - <ListboxOptions anchor='top end' className="absolute z-50 w-40 sm:w-44 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> - {availableVoices.map((voiceId) => ( - <ListboxOption - key={voiceId} - value={voiceId} - className={({ active, selected }) => - `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` - } - > - <span className='text-xs sm:text-sm'>{voiceId}</span> - </ListboxOption> - ))} - </ListboxOptions> - </Listbox> - ) : ( - <Listbox value={currentVoice} onChange={setVoiceAndRestart}> - <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> - <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> - <span>{currentVoice}</span> - <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> - </ListboxButton> - <ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> - {availableVoices.map((voiceId) => ( - <ListboxOption - key={voiceId} - value={voiceId} - className={({ active, selected }) => - `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` - } - > - <span className='text-xs sm:text-sm'>{voiceId}</span> - </ListboxOption> - ))} - </ListboxOptions> - </Listbox> - )} - </div> + <VoicesControlBase + availableVoices={availableVoices} + voice={voice || ''} + onChangeVoice={onChangeVoice} + ttsProvider={ttsProvider} + ttsModel={ttsModel} + /> ); -} \ No newline at end of file +} diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx new file mode 100644 index 0000000..1501586 --- /dev/null +++ b/src/components/player/VoicesControlBase.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from '@headlessui/react'; +import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons'; +import { useEffect, useMemo, useState } from 'react'; +import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/utils/voice'; + +export function VoicesControlBase({ + availableVoices, + voice, + onChangeVoice, + ttsProvider, + ttsModel, +}: { + availableVoices: string[]; + voice: string; + onChangeVoice: (voice: string) => void; + ttsProvider: string; + ttsModel: string; +}) { + const isKokoro = isKokoroModel(ttsModel); + const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel); + + const [selectedVoices, setSelectedVoices] = useState<string[]>([]); + + useEffect(() => { + if (!(isKokoro && maxVoices > 1)) return; + let initial: string[] = []; + if (voice && voice.includes('+')) { + initial = parseKokoroVoiceNames(voice); + } else if (voice && availableVoices.includes(voice)) { + initial = [voice]; + } else if (availableVoices.length > 0) { + initial = [availableVoices[0]]; + } + if (initial.length > maxVoices) { + initial = initial.slice(0, maxVoices); + } + setSelectedVoices(initial); + }, [isKokoro, maxVoices, voice, availableVoices]); + + const currentVoice = useMemo(() => { + if (isKokoro && maxVoices > 1) { + const combined = buildKokoroVoiceString(selectedVoices); + return combined || (availableVoices[0] || ''); + } + return voice && availableVoices.includes(voice) ? voice : availableVoices[0] || ''; + }, [isKokoro, maxVoices, selectedVoices, availableVoices, voice]); + + if (availableVoices.length === 0) { + return ( + <div className="relative"> + <div className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-muted text-xs sm:text-sm rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1"> + <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + <span>No voices</span> + </div> + </div> + ); + } + + return ( + <div className="relative"> + {isKokoro && maxVoices > 1 ? ( + <Listbox + multiple + value={selectedVoices} + onChange={(vals: string[]) => { + if (!vals || vals.length === 0) return; + + let next = vals; + if (vals.length > maxVoices) { + const newlyAdded = vals.find((v) => !selectedVoices.includes(v)); + if (newlyAdded) { + const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? ''; + const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean); + next = pair.slice(0, maxVoices); + } else { + next = vals.slice(-maxVoices); + } + } + + setSelectedVoices(next); + const combined = buildKokoroVoiceString(next); + if (combined) { + onChangeVoice(combined); + } + }} + > + <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> + <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + <span>{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}</span> + <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> + </ListboxButton> + <ListboxOptions anchor="top end" className="absolute z-50 w-40 sm:w-44 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + {availableVoices.map((voiceId) => ( + <ListboxOption + key={voiceId} + value={voiceId} + className={({ active, selected }) => + `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` + } + > + <span className="text-xs sm:text-sm">{voiceId}</span> + </ListboxOption> + ))} + </ListboxOptions> + </Listbox> + ) : ( + <Listbox value={currentVoice} onChange={onChangeVoice}> + <ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> + <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + <span>{currentVoice}</span> + <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> + </ListboxButton> + <ListboxOptions anchor="top end" className="absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + {availableVoices.map((voiceId) => ( + <ListboxOption + key={voiceId} + value={voiceId} + className={({ active, selected }) => + `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` + } + > + <span className="text-xs sm:text-sm">{voiceId}</span> + </ListboxOption> + ))} + </ListboxOptions> + </Listbox> + )} + </div> + ); +} + diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 5089775..9b8a57e 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,9 +1,10 @@ 'use client'; -import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; +import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db, initDB, updateAppConfig } from '@/lib/dexie'; +import { db, getDocumentIdMappings, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; +import toast from 'react-hot-toast'; export type { ViewType } from '@/types/config'; /** Configuration values for the application */ @@ -48,10 +49,32 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined); export function ConfigProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); const [isDBReady, setIsDBReady] = useState(false); + const didRunStartupMigrations = useRef(false); // Helper function to generate provider-model key const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`; + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail; + const status = detail?.status; + if (status === 'opened') { + toast.dismiss('dexie-blocked'); + return; + } + if (status === 'blocked' || status === 'stalled') { + const message = + 'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.'; + toast.error(message, { id: 'dexie-blocked', duration: Infinity }); + } + }; + + window.addEventListener('openreader:dexie', handler as EventListener); + return () => { + window.removeEventListener('openreader:dexie', handler as EventListener); + }; + }, []); + useEffect(() => { const initializeDB = async () => { try { @@ -68,6 +91,31 @@ export function ConfigProvider({ children }: { children: ReactNode }) { initializeDB(); }, []); + useEffect(() => { + if (!isDBReady) return; + if (didRunStartupMigrations.current) return; + didRunStartupMigrations.current = true; + + const run = async () => { + try { + await migrateLegacyDexieDocumentIdsToSha(); + const mappings = await getDocumentIdMappings(); + + // Run server-side v1 migrations proactively, since the client may now + // reference SHA-based IDs immediately after the Dexie migration. + await fetch('/api/migrations/v1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mappings }), + }).catch(() => undefined); + } catch (error) { + console.warn('Startup migrations failed:', error); + } + }; + + void run(); + }, [isDBReady]); + const appConfig = useLiveQuery( async () => { if (!isDBReady) return null; diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 365295f..f9e7db1 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -32,6 +32,7 @@ import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions, + AudiobookGenerationSettings, } from '@/types/client'; interface EPUBContextType { @@ -43,8 +44,21 @@ interface EPUBContextType { setCurrentDocument: (id: string) => Promise<void>; clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>; - createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>; - regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>; + createFullAudioBook: ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + onChapterComplete?: (chapter: TTSAudiobookChapter) => void, + bookId?: string, + format?: TTSAudiobookFormat, + settings?: AudiobookGenerationSettings + ) => Promise<string>; + regenerateChapter: ( + chapterIndex: number, + bookId: string, + format: TTSAudiobookFormat, + signal: AbortSignal, + settings?: AudiobookGenerationSettings + ) => Promise<TTSAudiobookChapter>; bookRef: RefObject<Book | null>; renditionRef: RefObject<Rendition | undefined>; tocRef: RefObject<NavItem[]>; @@ -325,12 +339,26 @@ export function EPUBProvider({ children }: { children: ReactNode }) { signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, providedBookId?: string, - format: TTSAudiobookFormat = 'mp3' + format: TTSAudiobookFormat = 'mp3', + settings?: AudiobookGenerationSettings ): Promise<string> => { try { const sections = await extractBookText(); if (!sections.length) throw new Error('No text content found in book'); + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + // Calculate total length for accurate progress tracking const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0); let processedLength = 0; @@ -406,16 +434,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text: trimmedText, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -459,8 +487,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex: i + format: effectiveFormat, + chapterIndex: i, + settings }, signal); if (!bookId) { @@ -492,7 +521,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`, status: 'error', bookId, - format + format: effectiveFormat }); } } @@ -516,7 +545,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) { chapterIndex: number, bookId: string, format: TTSAudiobookFormat, - signal: AbortSignal + signal: AbortSignal, + settings?: AudiobookGenerationSettings ): Promise<TTSAudiobookChapter> => { try { const sections = await extractBookText(); @@ -524,6 +554,19 @@ export function EPUBProvider({ children }: { children: ReactNode }) { throw new Error('Invalid chapter index'); } + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + const section = sections[chapterIndex]; const trimmedText = section.text.trim(); @@ -557,16 +600,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text: trimmedText, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -596,8 +639,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex + format: effectiveFormat, + chapterIndex, + settings }, signal); return chapter; diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 72a2340..a771452 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -50,6 +50,7 @@ import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions, + AudiobookGenerationSettings, } from '@/types/client'; /** @@ -77,8 +78,21 @@ interface PDFContextType { sentence: string | null | undefined, containerRef: RefObject<HTMLDivElement> ) => void; - createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>; - regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>; + createFullAudioBook: ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + onChapterComplete?: (chapter: TTSAudiobookChapter) => void, + bookId?: string, + format?: TTSAudiobookFormat, + settings?: AudiobookGenerationSettings + ) => Promise<string>; + regenerateChapter: ( + chapterIndex: number, + bookId: string, + format: TTSAudiobookFormat, + signal: AbortSignal, + settings?: AudiobookGenerationSettings + ) => Promise<TTSAudiobookChapter>; isAudioCombining: boolean; } @@ -264,13 +278,27 @@ export function PDFProvider({ children }: { children: ReactNode }) { signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, providedBookId?: string, - format: TTSAudiobookFormat = 'mp3' + format: TTSAudiobookFormat = 'mp3', + settings?: AudiobookGenerationSettings ): Promise<string> => { try { if (!pdfDocument) { throw new Error('No PDF document loaded'); } + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + // First pass: extract and measure all text const textPerPage: string[] = []; let totalLength = 0; @@ -342,16 +370,16 @@ export function PDFProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -390,8 +418,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex: i + format: effectiveFormat, + chapterIndex: i, + settings }, signal); if (!bookId) { @@ -423,7 +452,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { title: `Page ${i + 1}`, status: 'error', bookId, - format + format: effectiveFormat }); } } @@ -447,13 +476,27 @@ export function PDFProvider({ children }: { children: ReactNode }) { chapterIndex: number, bookId: string, format: TTSAudiobookFormat, - signal: AbortSignal + signal: AbortSignal, + settings?: AudiobookGenerationSettings ): Promise<TTSAudiobookChapter> => { try { if (!pdfDocument) { throw new Error('No PDF document loaded'); } + const effectiveProvider = settings?.ttsProvider ?? ttsProvider; + const effectiveModel = settings?.ttsModel ?? ttsModel; + const effectiveVoice = + settings?.voice || + voice || + (effectiveProvider === 'openai' + ? 'alloy' + : effectiveProvider === 'deepinfra' + ? 'af_bella' + : 'af_sarah'); + const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed; + const effectiveFormat = settings?.format ?? format; + // IMPORTANT: Chapter indices are based on non-empty pages used during generation. // Build a mapping of "chapterIndex" -> actual PDF page number (1-based). const nonEmptyPages: number[] = []; @@ -500,16 +543,16 @@ export function PDFProvider({ children }: { children: ReactNode }) { 'Content-Type': 'application/json', 'x-openai-key': apiKey, 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, + 'x-tts-provider': effectiveProvider, }; const reqBody: TTSRequestPayload = { text: textForTTS, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, + voice: effectiveVoice, + speed: effectiveNativeSpeed, format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + model: effectiveModel, + instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; const retryOptions: TTSRetryOptions = { @@ -539,8 +582,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { chapterTitle, buffer: Array.from(new Uint8Array(audioBuffer)), bookId, - format, - chapterIndex + format: effectiveFormat, + chapterIndex, + settings }, signal); return chapter; diff --git a/src/hooks/epub/useEPUBDocuments.ts b/src/hooks/epub/useEPUBDocuments.ts index d09a170..6e58477 100644 --- a/src/hooks/epub/useEPUBDocuments.ts +++ b/src/hooks/epub/useEPUBDocuments.ts @@ -1,10 +1,10 @@ 'use client'; import { useCallback } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/dexie'; import type { EPUBDocument } from '@/types/documents'; +import { sha256HexFromArrayBuffer } from '@/lib/sha256'; export function useEPUBDocuments() { const documents = useLiveQuery( @@ -16,8 +16,8 @@ export function useEPUBDocuments() { const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise<string> => { - const id = uuidv4(); const arrayBuffer = await file.arrayBuffer(); + const id = await sha256HexFromArrayBuffer(arrayBuffer); console.log('Original file size:', file.size); console.log('ArrayBuffer size:', arrayBuffer.byteLength); @@ -31,7 +31,7 @@ export function useEPUBDocuments() { data: arrayBuffer, }; - await db['epub-documents'].add(newDoc); + await db['epub-documents'].put(newDoc); return id; }, []); diff --git a/src/hooks/html/useHTMLDocuments.ts b/src/hooks/html/useHTMLDocuments.ts index 593ef5d..b5d620c 100644 --- a/src/hooks/html/useHTMLDocuments.ts +++ b/src/hooks/html/useHTMLDocuments.ts @@ -1,10 +1,10 @@ 'use client'; import { useCallback } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/dexie'; import type { HTMLDocument } from '@/types/documents'; +import { sha256HexFromString } from '@/lib/sha256'; export function useHTMLDocuments() { const documents = useLiveQuery( @@ -16,8 +16,10 @@ export function useHTMLDocuments() { const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise<string> => { - const id = uuidv4(); - const content = await file.text(); + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const content = new TextDecoder().decode(bytes); + const id = await sha256HexFromString(content); const newDoc: HTMLDocument = { id, @@ -28,7 +30,7 @@ export function useHTMLDocuments() { data: content, }; - await db['html-documents'].add(newDoc); + await db['html-documents'].put(newDoc); return id; }, []); diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts index bfe3425..04047e2 100644 --- a/src/hooks/pdf/usePDFDocuments.ts +++ b/src/hooks/pdf/usePDFDocuments.ts @@ -1,10 +1,10 @@ 'use client'; import { useCallback } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/dexie'; import type { PDFDocument } from '@/types/documents'; +import { sha256HexFromArrayBuffer } from '@/lib/sha256'; export function usePDFDocuments() { const documents = useLiveQuery( @@ -16,8 +16,8 @@ export function usePDFDocuments() { const isLoading = documents === undefined; const addDocument = useCallback(async (file: File): Promise<string> => { - const id = uuidv4(); const arrayBuffer = await file.arrayBuffer(); + const id = await sha256HexFromArrayBuffer(arrayBuffer); const newDoc: PDFDocument = { id, @@ -28,7 +28,7 @@ export function usePDFDocuments() { data: arrayBuffer, }; - await db['pdf-documents'].add(newDoc); + await db['pdf-documents'].put(newDoc); return id; }, []); diff --git a/src/lib/client.ts b/src/lib/client.ts index f6ca16d..4481f36 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -116,8 +116,14 @@ export const createAudiobookChapter = async ( throw new Error('cancelled'); } + if (response.status === 409) { + const data = await response.json().catch(() => null) as { error?: string } | null; + throw new Error(data?.error || 'Audiobook settings mismatch'); + } + if (!response.ok) { - throw new Error('Failed to convert audio chapter'); + const data = await response.json().catch(() => null) as { error?: string } | null; + throw new Error(data?.error || 'Failed to convert audio chapter'); } return await response.json(); diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts index 3fe1d72..9b671e6 100644 --- a/src/lib/dexie.ts +++ b/src/lib/dexie.ts @@ -1,10 +1,19 @@ import Dexie, { type EntityTable } from 'dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config'; -import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents'; +import { + PDFDocument, + EPUBDocument, + HTMLDocument, + DocumentListState, + SyncedDocument, + BaseDocument, + DocumentListDocument, +} from '@/types/documents'; +import { sha256HexFromBytes, sha256HexFromString } from '@/lib/sha256'; const DB_NAME = 'openreader-db'; // Managed via Dexie (version bumped from the original manual IndexedDB) -const DB_VERSION = 5; +const DB_VERSION = 6; const PDF_TABLE = 'pdf-documents' as const; const EPUB_TABLE = 'epub-documents' as const; @@ -12,12 +21,19 @@ const HTML_TABLE = 'html-documents' as const; const CONFIG_TABLE = 'config' as const; const APP_CONFIG_TABLE = 'app-config' as const; const LAST_LOCATION_TABLE = 'last-locations' as const; +const DOCUMENT_ID_MAP_TABLE = 'document-id-map' as const; export interface LastLocationRow { docId: string; location: string; } +export interface DocumentIdMapRow { + oldId: string; + id: string; + createdAt: number; +} + export interface ConfigRow { key: string; value: string; @@ -30,12 +46,35 @@ type OpenReaderDB = Dexie & { [CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>; [APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>; [LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>; + [DOCUMENT_ID_MAP_TABLE]: EntityTable<DocumentIdMapRow, 'oldId'>; }; export const db = new Dexie(DB_NAME) as OpenReaderDB; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; +type DexieOpenStatus = 'opening' | 'opened' | 'blocked' | 'stalled' | 'error'; + +function emitDexieStatus(status: DexieOpenStatus, detail?: Record<string, unknown>): void { + if (typeof window === 'undefined') return; + try { + window.dispatchEvent( + new CustomEvent('openreader:dexie', { + detail: { status, ...detail }, + }), + ); + } catch { + // ignore + } +} + +if (typeof window !== 'undefined') { + // Fired when this tab's open/upgrade is blocked by another tab holding the DB open. + db.on('blocked', () => { + emitDexieStatus('blocked'); + }); +} + const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = { openai: 'https://api.openai.com/v1', deepinfra: 'https://api.deepinfra.com/v1/openai', @@ -152,7 +191,7 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { return config; } -// Version 5: introduce app-config and last-locations tables, migrate scattered config keys, +// Version 6: add document-id-map table; keep v5 upgrade to migrate scattered config keys // and drop the legacy config table in a single upgrade step. db.version(DB_VERSION).stores({ [PDF_TABLE]: 'id, type, name, lastModified, size, folderId', @@ -160,6 +199,7 @@ db.version(DB_VERSION).stores({ [HTML_TABLE]: 'id, type, name, lastModified, size, folderId', [APP_CONFIG_TABLE]: 'id', [LAST_LOCATION_TABLE]: 'docId', + [DOCUMENT_ID_MAP_TABLE]: 'oldId, id, createdAt', // `null` here means: drop the old 'config' table after upgrade runs, // but Dexie still lets us read it inside the upgrade transaction. [CONFIG_TABLE]: null, @@ -199,10 +239,18 @@ export async function initDB(): Promise<void> { dbOpenPromise = (async () => { try { console.log('Opening Dexie database...'); + emitDexieStatus('opening'); + const startedAt = Date.now(); + const stallTimer = setTimeout(() => { + emitDexieStatus('stalled', { ms: Date.now() - startedAt }); + }, 4000); await db.open(); + clearTimeout(stallTimer); console.log('Dexie database opened successfully'); + emitDexieStatus('opened'); } catch (error) { console.error('Dexie initialization error:', error); + emitDexieStatus('error', { message: error instanceof Error ? error.message : String(error) }); dbOpenPromise = null; throw error; } @@ -216,6 +264,193 @@ async function withDB<T>(operation: () => Promise<T>): Promise<T> { return operation(); } +function isSha256HexId(value: string): boolean { + return /^[a-f0-9]{64}$/i.test(value); +} + +async function getMappedDocumentId(docId: string): Promise<string> { + if (isSha256HexId(docId)) return docId.toLowerCase(); + const row = await db[DOCUMENT_ID_MAP_TABLE].get(docId); + return row?.id ?? docId; +} + +export async function resolveDocumentId(docId: string): Promise<string> { + return withDB(async () => getMappedDocumentId(docId)); +} + +async function recordDocumentIdMapping(oldId: string, id: string): Promise<void> { + if (oldId === id) return; + await db[DOCUMENT_ID_MAP_TABLE].put({ oldId, id, createdAt: Date.now() }); +} + +function rewriteDocumentListStateDocIds(state: DocumentListState, mapping: Map<string, string>): DocumentListState { + let didChange = false; + + const folders = state.folders.map((folder) => { + let folderChanged = false; + const seen = new Set<string>(); + const documents: DocumentListDocument[] = []; + + for (const doc of folder.documents) { + const mappedId = mapping.get(doc.id) ?? doc.id; + if (mappedId !== doc.id) folderChanged = true; + if (seen.has(mappedId)) { + folderChanged = true; + continue; + } + seen.add(mappedId); + documents.push(mappedId === doc.id ? doc : { ...doc, id: mappedId }); + } + + if (!folderChanged) return folder; + didChange = true; + return { ...folder, documents }; + }); + + return didChange ? { ...state, folders } : state; +} + +async function applyDocumentIdMapping(oldId: string, newId: string): Promise<void> { + if (!oldId || !newId || oldId === newId) return; + const nextId = newId.toLowerCase(); + + await withDB(async () => { + await db.transaction( + 'readwrite', + [ + db[PDF_TABLE], + db[EPUB_TABLE], + db[HTML_TABLE], + db[LAST_LOCATION_TABLE], + db[APP_CONFIG_TABLE], + db[DOCUMENT_ID_MAP_TABLE], + ], + async () => { + await recordDocumentIdMapping(oldId, nextId); + + const pdf = await db[PDF_TABLE].get(oldId); + if (pdf) { + const existing = await db[PDF_TABLE].get(nextId); + if (existing) { + const merged: PDFDocument = { + ...pdf, + ...existing, + id: nextId, + folderId: existing.folderId ?? pdf.folderId, + name: existing.name || pdf.name, + }; + await db[PDF_TABLE].put(merged); + await db[PDF_TABLE].delete(oldId); + } else { + await db[PDF_TABLE].put({ ...pdf, id: nextId }); + await db[PDF_TABLE].delete(oldId); + } + } + + const epub = await db[EPUB_TABLE].get(oldId); + if (epub) { + const existing = await db[EPUB_TABLE].get(nextId); + if (existing) { + const merged: EPUBDocument = { + ...epub, + ...existing, + id: nextId, + folderId: existing.folderId ?? epub.folderId, + name: existing.name || epub.name, + }; + await db[EPUB_TABLE].put(merged); + await db[EPUB_TABLE].delete(oldId); + } else { + await db[EPUB_TABLE].put({ ...epub, id: nextId }); + await db[EPUB_TABLE].delete(oldId); + } + } + + const html = await db[HTML_TABLE].get(oldId); + if (html) { + const existing = await db[HTML_TABLE].get(nextId); + if (existing) { + const merged: HTMLDocument = { + ...html, + ...existing, + id: nextId, + folderId: existing.folderId ?? html.folderId, + name: existing.name || html.name, + }; + await db[HTML_TABLE].put(merged); + await db[HTML_TABLE].delete(oldId); + } else { + await db[HTML_TABLE].put({ ...html, id: nextId }); + await db[HTML_TABLE].delete(oldId); + } + } + + const oldLocation = await db[LAST_LOCATION_TABLE].get(oldId); + if (oldLocation) { + const newLocation = await db[LAST_LOCATION_TABLE].get(nextId); + if (!newLocation) { + await db[LAST_LOCATION_TABLE].put({ docId: nextId, location: oldLocation.location }); + } + await db[LAST_LOCATION_TABLE].delete(oldId); + } + + const appConfig = await db[APP_CONFIG_TABLE].get('singleton'); + if (appConfig?.documentListState) { + const mapped = rewriteDocumentListStateDocIds(appConfig.documentListState, new Map([[oldId, nextId]])); + if (mapped !== appConfig.documentListState) { + await db[APP_CONFIG_TABLE].update('singleton', { documentListState: mapped }); + } + } + }, + ); + }); +} + +export async function migrateLegacyDexieDocumentIdsToSha(): Promise<Array<{ oldId: string; id: string }>> { + return withDB(async () => { + const mappings: Array<{ oldId: string; id: string }> = []; + + const pdfDocs = await db[PDF_TABLE].toArray(); + for (const doc of pdfDocs) { + if (isSha256HexId(doc.id)) continue; + const id = await sha256HexFromBytes(new Uint8Array(doc.data)); + if (id !== doc.id) { + mappings.push({ oldId: doc.id, id }); + await applyDocumentIdMapping(doc.id, id); + } + } + + const epubDocs = await db[EPUB_TABLE].toArray(); + for (const doc of epubDocs) { + if (isSha256HexId(doc.id)) continue; + const id = await sha256HexFromBytes(new Uint8Array(doc.data)); + if (id !== doc.id) { + mappings.push({ oldId: doc.id, id }); + await applyDocumentIdMapping(doc.id, id); + } + } + + const htmlDocs = await db[HTML_TABLE].toArray(); + for (const doc of htmlDocs) { + if (isSha256HexId(doc.id)) continue; + const id = await sha256HexFromString(doc.data); + if (id !== doc.id) { + mappings.push({ oldId: doc.id, id }); + await applyDocumentIdMapping(doc.id, id); + } + } + + return mappings; + }); +} + +export async function getDocumentIdMappings(): Promise<Array<{ oldId: string; id: string }>> { + return withDB(async () => { + const rows = await db[DOCUMENT_ID_MAP_TABLE].toArray(); + return rows.map((row) => ({ oldId: row.oldId, id: row.id })); + }); +} + // PDF document helpers export async function addPdfDocument(document: PDFDocument): Promise<void> { @@ -228,7 +463,8 @@ export async function addPdfDocument(document: PDFDocument): Promise<void> { export async function getPdfDocument(id: string): Promise<PDFDocument | undefined> { return withDB(async () => { console.log('Fetching PDF document via Dexie:', id); - return db[PDF_TABLE].get(id); + const resolved = await getMappedDocumentId(id); + return db[PDF_TABLE].get(resolved); }); } @@ -242,9 +478,10 @@ export async function getAllPdfDocuments(): Promise<PDFDocument[]> { export async function removePdfDocument(id: string): Promise<void> { await withDB(async () => { console.log('Removing PDF document via Dexie:', id); + const resolved = await getMappedDocumentId(id); await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], async () => { - await db[PDF_TABLE].delete(id); - await db[LAST_LOCATION_TABLE].delete(id); + await db[PDF_TABLE].delete(resolved); + await db[LAST_LOCATION_TABLE].delete(resolved); }); }); } @@ -277,7 +514,8 @@ export async function addEpubDocument(document: EPUBDocument): Promise<void> { export async function getEpubDocument(id: string): Promise<EPUBDocument | undefined> { return withDB(async () => { console.log('Fetching EPUB document via Dexie:', id); - return db[EPUB_TABLE].get(id); + const resolved = await getMappedDocumentId(id); + return db[EPUB_TABLE].get(resolved); }); } @@ -291,9 +529,10 @@ export async function getAllEpubDocuments(): Promise<EPUBDocument[]> { export async function removeEpubDocument(id: string): Promise<void> { await withDB(async () => { console.log('Removing EPUB document via Dexie:', id); + const resolved = await getMappedDocumentId(id); await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], async () => { - await db[EPUB_TABLE].delete(id); - await db[LAST_LOCATION_TABLE].delete(id); + await db[EPUB_TABLE].delete(resolved); + await db[LAST_LOCATION_TABLE].delete(resolved); }); }); } @@ -317,7 +556,8 @@ export async function addHtmlDocument(document: HTMLDocument): Promise<void> { export async function getHtmlDocument(id: string): Promise<HTMLDocument | undefined> { return withDB(async () => { console.log('Fetching HTML document via Dexie:', id); - return db[HTML_TABLE].get(id); + const resolved = await getMappedDocumentId(id); + return db[HTML_TABLE].get(resolved); }); } @@ -331,7 +571,8 @@ export async function getAllHtmlDocuments(): Promise<HTMLDocument[]> { export async function removeHtmlDocument(id: string): Promise<void> { await withDB(async () => { console.log('Removing HTML document via Dexie:', id); - await db[HTML_TABLE].delete(id); + const resolved = await getMappedDocumentId(id); + await db[HTML_TABLE].delete(resolved); }); } @@ -382,14 +623,16 @@ export async function getDocumentListState(): Promise<DocumentListState | null> export async function getLastDocumentLocation(docId: string): Promise<string | null> { return withDB(async () => { - const row = await db[LAST_LOCATION_TABLE].get(docId); + const resolved = await getMappedDocumentId(docId); + const row = await db[LAST_LOCATION_TABLE].get(resolved); return row ? row.location : null; }); } export async function setLastDocumentLocation(docId: string, location: string): Promise<void> { await withDB(async () => { - await db[LAST_LOCATION_TABLE].put({ docId, location }); + const resolved = await getMappedDocumentId(docId); + await db[LAST_LOCATION_TABLE].put({ docId: resolved, location }); }); } @@ -471,6 +714,16 @@ export async function syncDocumentsToServer( throw new Error('Failed to sync documents to server'); } + const payload = (await response.json().catch(() => null)) as + | { stored?: Array<{ oldId: string; id: string }> } + | null; + const stored = payload?.stored ?? []; + for (const mapping of stored) { + if (!mapping || typeof mapping.oldId !== 'string' || typeof mapping.id !== 'string') continue; + if (mapping.oldId === mapping.id) continue; + await applyDocumentIdMapping(mapping.oldId, mapping.id); + } + if (onProgress) { onProgress(100, 'Upload complete!'); } @@ -555,3 +808,90 @@ export async function loadDocumentsFromServer( return { lastSync: Date.now() }; } + +export async function importDocumentsFromLibrary( + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +): Promise<void> { + if (onProgress) { + onProgress(5, 'Scanning server library...'); + } + + const listResponse = await fetch('/api/documents/library', { signal }); + if (!listResponse.ok) { + throw new Error('Failed to list library documents'); + } + + const { documents } = (await listResponse.json()) as { documents: BaseDocument[] }; + + if (documents.length === 0) { + if (onProgress) { + onProgress(100, 'No documents found in server library'); + } + return; + } + + if (onProgress) { + onProgress(10, `Found ${documents.length} documents. Importing...`); + } + + const textDecoder = new TextDecoder(); + + for (let i = 0; i < documents.length; i++) { + const doc = documents[i]; + + if (onProgress) { + onProgress(10 + (i / documents.length) * 85, `Downloading ${i + 1}/${documents.length}: ${doc.name}`); + } + + const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, { signal }); + if (!contentResponse.ok) { + console.warn(`Failed to download library document: ${doc.name}`); + continue; + } + + const buffer = await contentResponse.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + if (doc.type === 'pdf') { + const localId = await sha256HexFromBytes(bytes); + await addPdfDocument({ + id: localId, + type: 'pdf', + name: doc.name, + size: bytes.byteLength, + lastModified: doc.lastModified, + data: buffer, + }); + } else if (doc.type === 'epub') { + const localId = await sha256HexFromBytes(bytes); + await addEpubDocument({ + id: localId, + type: 'epub', + name: doc.name, + size: bytes.byteLength, + lastModified: doc.lastModified, + data: buffer, + }); + } else { + const decoded = textDecoder.decode(bytes); + const localId = await sha256HexFromString(decoded); + await addHtmlDocument({ + id: localId, + type: 'html', + name: doc.name, + size: bytes.byteLength, + lastModified: doc.lastModified, + data: decoded, + }); + } + + if (onProgress) { + onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`); + } + } + + if (onProgress) { + onProgress(100, 'Library import complete!'); + } +} diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts index fc4c120..6f157cc 100644 --- a/src/lib/pdf.ts +++ b/src/lib/pdf.ts @@ -155,6 +155,33 @@ let lastSentenceTokenWindow: { start: number; end: number } | null = null; let lastSentencePattern: string | null = null; let lastSentenceWordToTokenMap: number[] | null = null; +function getOrCreateHighlightLayer(span: HTMLElement): { + layer: HTMLElement; + pageElement: HTMLElement; + pageRect: DOMRect; +} | null { + const pageElement = span.closest('.react-pdf__Page') as HTMLElement | null; + if (!pageElement) return null; + + let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null; + if (!layer) { + layer = document.createElement('div'); + layer.className = 'pdf-highlight-layer'; + pageElement.appendChild(layer); + } + + layer.style.position = 'absolute'; + layer.style.inset = '0'; + layer.style.pointerEvents = 'none'; + layer.style.zIndex = '4'; + layer.style.overflow = 'hidden'; + // Force a compositor layer to avoid Safari occasionally not painting + // newly-added positioned overlays. + layer.style.transform = 'translateZ(0)'; + + return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() }; +} + const normalizeWordForMatch = (text: string): string => text .trim() @@ -453,12 +480,10 @@ export function highlightPattern( range.setStart(textNode, startOffset); range.setEnd(textNode, endOffset); - const pageLayer = span.closest( - '.react-pdf__Page__textContent' - ) as HTMLElement | null; - if (!pageLayer) return; + const highlightTarget = getOrCreateHighlightLayer(span); + if (!highlightTarget) return; - const pageRect = pageLayer.getBoundingClientRect(); + const { layer: highlightLayer, pageRect } = highlightTarget; const rects = Array.from(range.getClientRects()); rects.forEach((rect) => { @@ -468,11 +493,12 @@ export function highlightPattern( highlight.style.backgroundColor = 'grey'; highlight.style.opacity = '0.4'; highlight.style.pointerEvents = 'none'; + highlight.style.zIndex = '1'; highlight.style.left = `${rect.left - pageRect.left}px`; highlight.style.top = `${rect.top - pageRect.top}px`; highlight.style.width = `${rect.width}px`; highlight.style.height = `${rect.height}px`; - pageLayer.appendChild(highlight); + highlightLayer.appendChild(highlight); scrollIntoViewRects.push(rect); }); @@ -688,12 +714,10 @@ export function highlightWordIndex( range.setStart(node, token.startOffset); range.setEnd(node, token.endOffset); - const pageLayer = span.closest( - '.react-pdf__Page__textContent' - ) as HTMLElement | null; - if (!pageLayer) return; + const highlightTarget = getOrCreateHighlightLayer(span); + if (!highlightTarget) return; - const pageRect = pageLayer.getBoundingClientRect(); + const { layer: highlightLayer, pageRect } = highlightTarget; const rects = Array.from(range.getClientRects()); rects.forEach((rect) => { @@ -708,7 +732,7 @@ export function highlightWordIndex( highlight.style.width = `${rect.width}px`; highlight.style.height = `${rect.height}px`; highlight.style.zIndex = '2'; - pageLayer.appendChild(highlight); + highlightLayer.appendChild(highlight); }); } catch { // Ignore range errors diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts new file mode 100644 index 0000000..2ef5c7a --- /dev/null +++ b/src/lib/server/audiobook.ts @@ -0,0 +1,215 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { readdir } from 'fs/promises'; + +export type StoredChapter = { + index: number; + title: string; + durationSec?: number; + format: 'mp3' | 'm4b'; + filePath: string; +}; + +function sanitizeTagValue(value: string): string { + return value.replaceAll('\u0000', '').replaceAll(/\r?\n/g, ' ').trim(); +} + +function sanitizeFileStem(value: string): string { + return sanitizeTagValue(value) + .replaceAll(/[\\/]/g, ' ') + .replaceAll(/[<>:"|?*\u0000]/g, '') + .replaceAll(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +export function encodeChapterTitleTag(index: number, title: string): string { + const safeTitle = sanitizeTagValue(title) || `Chapter ${index + 1}`; + const prefix = String(index + 1).padStart(4, '0'); + return `${prefix} - ${safeTitle}`; +} + +export function decodeChapterTitleTag(tag: string): { index: number; title: string } | null { + const raw = sanitizeTagValue(tag); + if (!raw) return null; + + const match = /^(\d{1,6})\s*[-.:]\s*(.+)$/.exec(raw); + if (!match) return null; + + const oneBased = Number(match[1]); + if (!Number.isFinite(oneBased) || !Number.isInteger(oneBased) || oneBased <= 0) return null; + + return { index: oneBased - 1, title: match[2].trim() || `Chapter ${oneBased}` }; +} + +export function encodeChapterFileName(index: number, title: string, format: 'mp3' | 'm4b'): string { + const oneBased = String(index + 1).padStart(4, '0'); + const safeTitle = sanitizeFileStem(title) || `Chapter ${index + 1}`; + return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`; +} + +function decodeChapterFileName(fileName: string): { index: number; title: string; format: 'mp3' | 'm4b' } | null { + const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName); + if (!match) return null; + const oneBased = Number(match[1]); + if (!Number.isInteger(oneBased) || oneBased <= 0) return null; + const format = match[3].toLowerCase() as 'mp3' | 'm4b'; + try { + const title = decodeURIComponent(match[2]); + return { index: oneBased - 1, title: title || `Chapter ${oneBased}`, format }; + } catch { + return { index: oneBased - 1, title: match[2], format }; + } +} + +type ProbeResult = { + durationSec?: number; + titleTag?: string; +}; + +export async function ffprobeAudio(filePath: string, signal?: AbortSignal): Promise<ProbeResult> { + return new Promise<ProbeResult>((resolve, reject) => { + const ffprobe = spawn('ffprobe', [ + '-v', + 'quiet', + '-print_format', + 'json', + '-show_entries', + 'format=duration:format_tags=title', + filePath, + ]); + + let output = ''; + let finished = false; + + const onAbort = () => { + if (finished) return; + finished = true; + try { + ffprobe.kill('SIGKILL'); + } catch {} + reject(new Error('ABORTED')); + }; + + const cleanup = () => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + ffprobe.stdout.on('data', (data) => { + output += data.toString(); + }); + + ffprobe.on('close', (code) => { + if (finished) return; + cleanup(); + if (code !== 0) { + reject(new Error(`ffprobe process exited with code ${code}`)); + return; + } + + try { + const parsed = JSON.parse(output) as { + format?: { duration?: string; tags?: { title?: string } }; + }; + const durationStr = parsed.format?.duration; + const durationSec = durationStr ? Number(durationStr) : undefined; + resolve({ + durationSec: Number.isFinite(durationSec) ? durationSec : undefined, + titleTag: parsed.format?.tags?.title, + }); + } catch (error) { + reject(error); + } + }); + + ffprobe.on('error', (err) => { + if (finished) return; + cleanup(); + reject(err); + }); + }); +} + +export async function listStoredChapters(dir: string, signal?: AbortSignal): Promise<StoredChapter[]> { + let files: string[] = []; + try { + files = await readdir(dir); + } catch { + return []; + } + + const candidates = files.filter((file) => !file.startsWith('.')).filter((file) => !file.startsWith('complete.')); + + const results: StoredChapter[] = []; + for (const file of candidates) { + const decodedFromName = decodeChapterFileName(file); + if (!decodedFromName) continue; + + const filePath = path.join(dir, file); + + let durationSec: number | undefined; + try { + const probe = await ffprobeAudio(filePath, signal); + durationSec = probe.durationSec; + } catch {} + + results.push({ + index: decodedFromName.index, + title: decodedFromName.title, + durationSec, + format: decodedFromName.format, + filePath, + }); + } + + results.sort((a, b) => a.index - b.index); + return results; +} + +export async function findStoredChapterByIndex( + dir: string, + index: number, + signal?: AbortSignal, +): Promise<StoredChapter | null> { + let files: string[] = []; + try { + files = await readdir(dir); + } catch { + return null; + } + + const oneBasedPrefix = String(index + 1).padStart(4, '0') + '__'; + const candidate = files.find((file) => file.startsWith(oneBasedPrefix) && (file.endsWith('.mp3') || file.endsWith('.m4b'))); + if (!candidate) { + const chapters = await listStoredChapters(dir, signal); + return chapters.find((chapter) => chapter.index === index) ?? null; + } + + const decoded = decodeChapterFileName(candidate); + if (!decoded) return null; + + const filePath = path.join(dir, candidate); + let durationSec: number | undefined; + try { + const probe = await ffprobeAudio(filePath, signal); + durationSec = probe.durationSec; + } catch {} + + return { + index: decoded.index, + title: decoded.title, + durationSec, + format: decoded.format, + filePath, + }; +} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts new file mode 100644 index 0000000..b6dd3da --- /dev/null +++ b/src/lib/server/docstore.ts @@ -0,0 +1,538 @@ +import { createHash } from 'crypto'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { mkdir, readdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from 'fs/promises'; +import path from 'path'; +import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ffprobeAudio } from '@/lib/server/audiobook'; + +export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); +export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); + +const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); +const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json'); + +type MigrationState = { + documentsV1Migrated?: boolean; + audiobooksV1Migrated?: boolean; + updatedAt?: number; +}; + +type LegacyDocumentMetadata = { + id: string; + name: string; + size: number; + lastModified: number; + type: string; +}; + +function isLegacyDocumentMetadata(value: unknown): value is LegacyDocumentMetadata { + if (!value || typeof value !== 'object') return false; + const v = value as Record<string, unknown>; + return ( + typeof v.id === 'string' && + typeof v.name === 'string' && + typeof v.size === 'number' && + typeof v.lastModified === 'number' && + typeof v.type === 'string' + ); +} + +async function loadMigrationState(): Promise<MigrationState> { + try { + return JSON.parse(await readFile(MIGRATIONS_STATE_PATH, 'utf8')) as MigrationState; + } catch { + return {}; + } +} + +async function saveMigrationState(update: Partial<MigrationState>): Promise<void> { + const state = await loadMigrationState(); + const next: MigrationState = { + documentsV1Migrated: state.documentsV1Migrated, + audiobooksV1Migrated: state.audiobooksV1Migrated, + ...update, + updatedAt: Date.now(), + }; + await mkdir(MIGRATIONS_DIR, { recursive: true }); + await writeFile(MIGRATIONS_STATE_PATH, JSON.stringify(next, null, 2)); +} + +async function hasLegacyDocumentFiles(): Promise<boolean> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.json')) continue; + + const metadataPath = path.join(DOCSTORE_DIR, entry.name); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(metadataPath, 'utf8')); + } catch { + continue; + } + if (!isLegacyDocumentMetadata(parsed)) continue; + + const contentPath = path.join(DOCSTORE_DIR, `${parsed.id}.${parsed.type}`); + if (!existsSync(contentPath)) continue; + + return true; + } + + return false; +} + +export async function isDocumentsV1Ready(): Promise<boolean> { + if (!existsSync(DOCSTORE_DIR) || !existsSync(DOCUMENTS_V1_DIR)) return false; + const state = await loadMigrationState(); + if (!state.documentsV1Migrated) return false; + if (await hasLegacyDocumentFiles()) return false; + return true; +} + +function safeDocumentName(rawName: string, fallback: string): string { + const baseName = path.basename(rawName || fallback); + return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; +} + +export async function ensureDocumentsV1Ready(): Promise<void> { + await mkdir(DOCSTORE_DIR, { recursive: true }); + await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); + + const state = await loadMigrationState(); + if (state.documentsV1Migrated && !(await hasLegacyDocumentFiles())) { + return; + } + + if (!(await hasLegacyDocumentFiles())) { + await saveMigrationState({ documentsV1Migrated: true }); + return; + } + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + entries = []; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.json')) continue; + + const metadataPath = path.join(DOCSTORE_DIR, entry.name); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(metadataPath, 'utf8')); + } catch { + continue; + } + if (!isLegacyDocumentMetadata(parsed)) continue; + const metadata = parsed; + + const contentPath = path.join(DOCSTORE_DIR, `${metadata.id}.${metadata.type}`); + let contentStat: Awaited<ReturnType<typeof stat>>; + try { + contentStat = await stat(contentPath); + } catch { + continue; + } + if (!contentStat.isFile()) continue; + + const content = await readFile(contentPath); + const id = createHash('sha256').update(content).digest('hex'); + const fallbackName = `${id}.${metadata.type}`; + const name = safeDocumentName(metadata.name, fallbackName); + const targetFileName = `${id}__${encodeURIComponent(name)}`; + const targetPath = path.join(DOCUMENTS_V1_DIR, targetFileName); + + if (!existsSync(targetPath)) { + await writeFile(targetPath, content); + if (Number.isFinite(metadata.lastModified) && metadata.lastModified > 0) { + const stamp = new Date(metadata.lastModified); + await utimes(targetPath, stamp, stamp).catch(() => {}); + } + } + + await unlink(metadataPath).catch(() => {}); + await unlink(contentPath).catch(() => {}); + } + + await saveMigrationState({ documentsV1Migrated: !(await hasLegacyDocumentFiles()) }); +} + +async function hasLegacyAudiobookDirs(): Promise<boolean> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + return false; + } + + return entries.some((entry) => entry.isDirectory() && entry.name.endsWith('-audiobook')); +} + +async function hasLegacyAudiobookChapterLayout(): Promise<boolean> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + let files: string[] = []; + try { + files = await readdir(dir); + } catch { + continue; + } + + for (const file of files) { + // Per-audiobook settings file is the new format; ignore it. + if (file === 'audiobook.meta.json') continue; + + if (file.endsWith('.meta.json')) return true; + if (/^\d+-chapter\.(mp3|m4b)$/i.test(file)) return true; + if (/^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)) return true; + } + } + + return false; +} + +export async function isAudiobooksV1Ready(): Promise<boolean> { + if (!existsSync(DOCSTORE_DIR) || !existsSync(AUDIOBOOKS_V1_DIR)) return false; + const state = await loadMigrationState(); + if (!state.audiobooksV1Migrated) return false; + const legacyDirsPresent = await hasLegacyAudiobookDirs(); + const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); + if (legacyDirsPresent || legacyChaptersPresent) return false; + return true; +} + +async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { + let moved = 0; + let skipped = 0; + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(sourceDir, { withFileTypes: true }); + } catch { + return { moved, skipped }; + } + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await mkdir(targetPath, { recursive: true }); + const nested = await mergeDirectoryContents(sourcePath, targetPath); + moved += nested.moved; + skipped += nested.skipped; + + try { + const remaining = await readdir(sourcePath); + if (remaining.length === 0) { + await rm(sourcePath); + } + } catch {} + continue; + } + + if (!entry.isFile()) continue; + + if (existsSync(targetPath)) { + skipped++; + continue; + } + + try { + await rename(sourcePath, targetPath); + moved++; + } catch { + skipped++; + } + } + + return { moved, skipped }; +} + +export async function ensureAudiobooksV1Ready(): Promise<void> { + await mkdir(DOCSTORE_DIR, { recursive: true }); + await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true }); + + const state = await loadMigrationState(); + const legacyDirsPresent = await hasLegacyAudiobookDirs(); + const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); + + if (state.audiobooksV1Migrated && !legacyDirsPresent && !legacyChaptersPresent) { + const stateRaw = state as unknown as Record<string, unknown>; + const allowedKeys = new Set(['documentsV1Migrated', 'audiobooksV1Migrated', 'updatedAt']); + const hasExtraKeys = Object.keys(stateRaw).some((key) => !allowedKeys.has(key)); + if (hasExtraKeys) { + await saveMigrationState({ audiobooksV1Migrated: true }); + } + return; + } + + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); + } catch { + entries = []; + } + + if (legacyDirsPresent) { + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const sourceDir = path.join(DOCSTORE_DIR, entry.name); + const targetDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + + try { + if (!existsSync(targetDir)) { + await rename(sourceDir, targetDir); + continue; + } + + await mkdir(targetDir, { recursive: true }); + await mergeDirectoryContents(sourceDir, targetDir); + + try { + const remaining = await readdir(sourceDir); + if (remaining.length === 0) { + await rm(sourceDir); + } else { + console.warn(`Legacy audiobook dir not fully migrated (kept): ${sourceDir}`); + } + } catch {} + } catch (error) { + console.error('Error migrating legacy audiobook directory:', error); + throw error; + } + } + } + + if (legacyDirsPresent || legacyChaptersPresent) { + await normalizeAudiobookChapterLayout(); + } + + const finalLegacyRemaining = await hasLegacyAudiobookDirs(); + const finalLegacyChaptersRemaining = await hasLegacyAudiobookChapterLayout(); + await saveMigrationState({ audiobooksV1Migrated: !finalLegacyRemaining && !finalLegacyChaptersRemaining }); +} + +type LegacyChapterMeta = { + title?: string; + duration?: number; + index?: number; + format?: string; +}; + +async function runProcess(command: string, args: string[]): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn(command, args); + let stderr = ''; + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with code ${code}: ${stderr}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + +async function rewriteAudioTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise<void> { + const baseArgs = ['-y', '-i', inputPath, '-metadata', `title=${titleTag}`]; + if (format === 'mp3') { + await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-write_id3v2', '1', '-id3v2_version', '3', outputPath]); + return; + } + await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-f', 'mp4', outputPath]); +} + +async function transcodeWithTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise<void> { + if (format === 'mp3') { + await runProcess('ffmpeg', [ + '-y', + '-i', + inputPath, + '-c:a', + 'libmp3lame', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + outputPath, + ]); + return; + } + + await runProcess('ffmpeg', [ + '-y', + '-i', + inputPath, + '-c:a', + 'aac', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + '-f', + 'mp4', + outputPath, + ]); +} + +async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string): Promise<void> { + let files: string[] = []; + try { + files = await readdir(intermediateDir); + } catch { + return; + } + + // Remove any combined output files from older layouts. + await unlink(path.join(intermediateDir, 'complete.mp3')).catch(() => {}); + await unlink(path.join(intermediateDir, 'complete.m4b')).catch(() => {}); + await unlink(path.join(intermediateDir, 'metadata.txt')).catch(() => {}); + await unlink(path.join(intermediateDir, 'list.txt')).catch(() => {}); + + const metaFiles = files.filter((file) => file.endsWith('.meta.json')); + const migratedIndices = new Set<number>(); + + for (const metaFile of metaFiles) { + const metaPath = path.join(intermediateDir, metaFile); + let metaRaw: unknown; + try { + metaRaw = JSON.parse(await readFile(metaPath, 'utf8')); + } catch { + continue; + } + const meta = metaRaw as LegacyChapterMeta; + const index = Number(meta.index); + if (!Number.isFinite(index) || !Number.isInteger(index) || index < 0) continue; + + const format = meta.format === 'mp3' ? 'mp3' : 'm4b'; + const sourceAudio = path.join(intermediateDir, `${index}-chapter.${format}`); + if (!existsSync(sourceAudio)) { + await unlink(metaPath).catch(() => {}); + continue; + } + + const titleTag = encodeChapterTitleTag(index, meta.title ?? `Chapter ${index + 1}`); + const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); + + try { + await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); + } catch { + await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); + } + + const finalName = encodeChapterFileName(index, meta.title ?? `Chapter ${index + 1}`, format); + const finalPath = path.join(intermediateDir, finalName); + await unlink(finalPath).catch(() => {}); + await rename(taggedTemp, finalPath); + + await unlink(sourceAudio).catch(() => {}); + await unlink(metaPath).catch(() => {}); + migratedIndices.add(index); + } + + // Migrate any remaining legacy chapter files without metadata. + files = await readdir(intermediateDir).catch(() => []); + for (const file of files) { + const match = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(file); + if (!match) continue; + const index = Number(match[1]); + if (!Number.isInteger(index) || index < 0) continue; + if (migratedIndices.has(index)) continue; + + const format = match[2].toLowerCase() as 'mp3' | 'm4b'; + const sourceAudio = path.join(intermediateDir, file); + const titleTag = encodeChapterTitleTag(index, `Chapter ${index + 1}`); + const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); + + try { + await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); + } catch { + await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); + } + + const finalName = encodeChapterFileName(index, `Chapter ${index + 1}`, format); + const finalPath = path.join(intermediateDir, finalName); + await unlink(finalPath).catch(() => {}); + await rename(taggedTemp, finalPath); + + await unlink(sourceAudio).catch(() => {}); + } + + // Rename any sha-named chapter files from previous runs into the index__title scheme. + files = await readdir(intermediateDir).catch(() => []); + const shaCandidates = files.filter((file) => /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)); + for (const file of shaCandidates) { + const sourceAudio = path.join(intermediateDir, file); + const format = file.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b'; + + let decoded: { index: number; title: string } | null = null; + try { + const probe = await ffprobeAudio(sourceAudio); + decoded = probe.titleTag ? decodeChapterTitleTag(probe.titleTag) : null; + } catch { + decoded = null; + } + if (!decoded) continue; + + const finalName = encodeChapterFileName(decoded.index, decoded.title, format); + const finalPath = path.join(intermediateDir, finalName); + await unlink(finalPath).catch(() => {}); + await rename(sourceAudio, finalPath).catch(() => {}); + } + + // Remove any leftover input temp files. + files = await readdir(intermediateDir).catch(() => []); + for (const file of files) { + if (/^\d+-input\.mp3$/i.test(file)) { + await unlink(path.join(intermediateDir, file)).catch(() => {}); + } + if (file.endsWith('.meta.json')) { + await unlink(path.join(intermediateDir, file)).catch(() => {}); + } + } +} + +async function normalizeAudiobookChapterLayout(): Promise<void> { + let entries: Array<import('fs').Dirent> = []; + try { + entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + try { + await normalizeAudiobookDirectoryChapterLayout(dir); + } catch (error) { + console.error('Error migrating audiobook chapter layout:', error); + throw error; + } + } +} diff --git a/src/lib/server/library.ts b/src/lib/server/library.ts new file mode 100644 index 0000000..25cf35b --- /dev/null +++ b/src/lib/server/library.ts @@ -0,0 +1,48 @@ +import path from 'path'; + +export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +export const DEFAULT_LIBRARY_DIR = path.join(DOCSTORE_DIR, 'library'); + +export function parseLibraryRoots(): string[] { + const raw = process.env.OPENREADER_LIBRARY_DIRS ?? process.env.OPENREADER_LIBRARY_DIR ?? ''; + + const roots = raw + .split(/[,:;]/g) + .map((value) => value.trim()) + .filter(Boolean); + + if (roots.length > 0) { + return roots; + } + + return [DEFAULT_LIBRARY_DIR]; +} + +export function contentTypeForName(name: string): string { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.epub') return 'application/epub+zip'; + if (ext === '.md' || ext === '.mdown' || ext === '.markdown') return 'text/markdown; charset=utf-8'; + if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8'; + return 'text/plain; charset=utf-8'; +} + +export function decodeLibraryId(id: string): { rootIndex: number; relativePath: string } | null { + try { + const decoded = Buffer.from(id, 'base64url').toString('utf8'); + const sepIndex = decoded.indexOf(':'); + if (sepIndex <= 0) return null; + const rootIndex = Number(decoded.slice(0, sepIndex)); + if (!Number.isInteger(rootIndex) || rootIndex < 0) return null; + const relativePath = decoded.slice(sepIndex + 1); + if (!relativePath) return null; + return { rootIndex, relativePath }; + } catch { + return null; + } +} + +export function isPathWithinRoot(resolvedRoot: string, resolvedFile: string): boolean { + return resolvedFile === resolvedRoot || resolvedFile.startsWith(resolvedRoot + path.sep); +} + diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts new file mode 100644 index 0000000..d36f055 --- /dev/null +++ b/src/lib/sha256.ts @@ -0,0 +1,158 @@ +'use client'; + +function toHex(bytes: Uint8Array): string { + let hex = ''; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +} + +function rotr32(x: number, n: number): number { + return ((x >>> n) | (x << (32 - n))) >>> 0; +} + +function sha256HexSoftwareFromBytes(bytes: Uint8Array): string { + const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]); + + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + const w = new Uint32Array(64); + + const compress = (block: Uint8Array, offset: number) => { + for (let i = 0; i < 16; i++) { + const j = offset + i * 4; + w[i] = ((block[j]! << 24) | (block[j + 1]! << 16) | (block[j + 2]! << 8) | block[j + 3]!) >>> 0; + } + for (let i = 16; i < 64; i++) { + const s0 = (rotr32(w[i - 15]!, 7) ^ rotr32(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3)) >>> 0; + const s1 = (rotr32(w[i - 2]!, 17) ^ rotr32(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10)) >>> 0; + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + + let a = h0; + let b = h1; + let c = h2; + let d = h3; + let e = h4; + let f = h5; + let g = h6; + let h = h7; + + for (let i = 0; i < 64; i++) { + const S1 = (rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25)) >>> 0; + const ch = ((e & f) ^ (~e & g)) >>> 0; + const temp1 = (h + S1 + ch + K[i]! + w[i]!) >>> 0; + const S0 = (rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22)) >>> 0; + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0; + const temp2 = (S0 + maj) >>> 0; + + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + }; + + let offset = 0; + for (; offset + 64 <= bytes.length; offset += 64) { + compress(bytes, offset); + } + + const remainder = bytes.length - offset; + const tail = new Uint8Array(128); + tail.set(bytes.subarray(offset)); + tail[remainder] = 0x80; + + const bitLenHi = (bytes.length >>> 29) >>> 0; + const bitLenLo = (bytes.length << 3) >>> 0; + + const totalTailLen = remainder + 1 + 8 <= 64 ? 64 : 128; + const lenOffset = totalTailLen - 8; + tail[lenOffset] = (bitLenHi >>> 24) & 0xff; + tail[lenOffset + 1] = (bitLenHi >>> 16) & 0xff; + tail[lenOffset + 2] = (bitLenHi >>> 8) & 0xff; + tail[lenOffset + 3] = bitLenHi & 0xff; + tail[lenOffset + 4] = (bitLenLo >>> 24) & 0xff; + tail[lenOffset + 5] = (bitLenLo >>> 16) & 0xff; + tail[lenOffset + 6] = (bitLenLo >>> 8) & 0xff; + tail[lenOffset + 7] = bitLenLo & 0xff; + + compress(tail, 0); + if (totalTailLen === 128) { + compress(tail, 64); + } + + return ( + h0.toString(16).padStart(8, '0') + + h1.toString(16).padStart(8, '0') + + h2.toString(16).padStart(8, '0') + + h3.toString(16).padStart(8, '0') + + h4.toString(16).padStart(8, '0') + + h5.toString(16).padStart(8, '0') + + h6.toString(16).padStart(8, '0') + + h7.toString(16).padStart(8, '0') + ); +} + +let didWarnWebCryptoUnavailable = false; +let didWarnWebCryptoDigestFailed = false; + +export async function sha256HexFromBytes(bytes: Uint8Array): Promise<string> { + const subtle = globalThis.crypto?.subtle; + if (!subtle) { + if (!didWarnWebCryptoUnavailable) { + didWarnWebCryptoUnavailable = true; + console.warn('[sha256] WebCrypto unavailable; falling back to software SHA-256.'); + } + return sha256HexSoftwareFromBytes(bytes); + } + + try { + const digest = await subtle.digest('SHA-256', bytes as unknown as BufferSource); + return toHex(new Uint8Array(digest)); + } catch (error) { + if (!didWarnWebCryptoDigestFailed) { + didWarnWebCryptoDigestFailed = true; + console.warn('[sha256] WebCrypto digest failed; falling back to software SHA-256.', error); + } + return sha256HexSoftwareFromBytes(bytes); + } +} + +export async function sha256HexFromArrayBuffer(buffer: ArrayBuffer): Promise<string> { + return sha256HexFromBytes(new Uint8Array(buffer)); +} + +export async function sha256HexFromString(text: string): Promise<string> { + return sha256HexFromBytes(new TextEncoder().encode(text)); +} diff --git a/src/types/client.ts b/src/types/client.ts index 113b6ad..32a3b36 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -38,6 +38,16 @@ export interface AudiobookStatusResponse { chapters: TTSAudiobookChapter[]; bookId: string | null; hasComplete: boolean; + settings?: AudiobookGenerationSettings | null; +} + +export interface AudiobookGenerationSettings { + ttsProvider: string; + ttsModel: string; + voice: string; + nativeSpeed: number; + postSpeed: number; + format: TTSAudiobookFormat; } export interface CreateChapterPayload { @@ -46,6 +56,7 @@ export interface CreateChapterPayload { bookId: string; format: TTSAudiobookFormat; chapterIndex: number; + settings?: AudiobookGenerationSettings; } diff --git a/src/types/documents.ts b/src/types/documents.ts index c93b615..e1307f3 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -65,3 +65,8 @@ export interface DocumentListState { showHint: boolean; viewMode?: 'list' | 'grid'; } + +export interface LibraryDocument extends BaseDocument { + // `id` is a stable server-provided reference, not necessarily the same as the local document id. + id: string; +} diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 77324a7..83fc6df 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -73,6 +73,11 @@ async function expectChaptersBackendState(page: Page, bookId: string) { return json; } +async function resetAudiobookById(page: Page, bookId: string) { + const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); + expect(res.ok()).toBeTruthy(); +} + async function resetAudiobookIfPresent(page: Page) { const resetButtons = page.getByRole('button', { name: 'Reset' }); const count = await resetButtons.count(); @@ -88,9 +93,7 @@ async function resetAudiobookIfPresent(page: Page) { const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); await confirmReset.click(); - await expect( - page.getByText(/Generation will use current TTS playback options./i) - ).toBeVisible({ timeout: 60_000 }); + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); } test.describe('Audiobook export', () => { @@ -105,6 +108,7 @@ test.describe('Audiobook export', () => { // Capture the generated document/book id from the /pdf/[id] URL const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); // Open the audiobook export modal from the header button await openExportModal(page); @@ -155,6 +159,7 @@ test.describe('Audiobook export', () => { // URL should now be /epub/[id] const bookId = await getBookIdFromUrl(page, 'epub'); + await resetAudiobookById(page, bookId); // Open the audiobook export modal from the header button await openExportModal(page); @@ -220,6 +225,7 @@ test.describe('Audiobook export', () => { await uploadAndDisplay(page, 'sample.pdf'); const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); await openExportModal(page); await setContainerFormatToMP3(page); @@ -247,6 +253,7 @@ test.describe('Audiobook export', () => { await uploadAndDisplay(page, 'sample.pdf'); const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); await openExportModal(page); await setContainerFormatToMP3(page); @@ -265,10 +272,8 @@ test.describe('Audiobook export', () => { const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); await confirmReset.click(); - // After reset, the hint text for starting generation should re-appear - await expect( - page.getByText(/Generation will use current TTS playback options./i) - ).toBeVisible({ timeout: 60_000 }); + // After reset, generation should be startable again + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); // Backend should report no existing chapters for this bookId const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); @@ -285,6 +290,7 @@ test.describe('Audiobook export', () => { // Extract bookId from /pdf/[id] URL (for backend verification later) const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); // Open Export Audiobook modal await openExportModal(page); diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index dc56f38..7634396 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -21,6 +21,45 @@ test.describe('Document Upload Tests', () => { await expectDocumentListed(page, 'sample.txt'); }); + test('hashes text/HTML docs using UTF-8 encoded stored string', async ({ page }) => { + await uploadFile(page, 'sample.txt'); + await expectDocumentListed(page, 'sample.txt'); + + const result = await page.evaluate(async () => { + const idb = await new Promise<IDBDatabase>((resolve, reject) => { + const request = indexedDB.open('openreader-db'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + try { + const docs = await new Promise<any[]>((resolve, reject) => { + const tx = idb.transaction('html-documents', 'readonly'); + const store = tx.objectStore('html-documents'); + const request = store.getAll(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as any[]); + }); + + if (!docs[0]?.data || !docs[0]?.id) { + return { ok: false, reason: 'Missing stored html document' as const }; + } + + const bytes = new TextEncoder().encode(String(docs[0].data)); + const digest = await crypto.subtle.digest('SHA-256', bytes); + const computedId = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return { ok: computedId === docs[0].id, storedId: docs[0].id as string, computedId }; + } finally { + idb.close(); + } + }); + + expect(result.ok, `Expected storedId=${(result as any).storedId} computedId=${(result as any).computedId}`).toBeTruthy(); + }); + test('uploads and converts a DOCX document', async ({ page }) => { // This test only runs in development mode test.skip(process.env.NODE_ENV === 'production', 'DOCX upload is only available in development mode');