+ )}
{showRegenerateHint && (
@@ -681,11 +838,11 @@ export function AudiobookExportModal({
)}
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
-
+
- Generation will use current TTS playback options.
-
- Individual chapters will appear here as they are generated.
+ Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
+
+ You can close this dialog while the audiobook is being generated. But returning to the home screen will cancel the generation.
)}
diff --git a/src/components/DocumentSelectionModal.tsx b/src/components/DocumentSelectionModal.tsx
new file mode 100644
index 0000000..e03af18
--- /dev/null
+++ b/src/components/DocumentSelectionModal.tsx
@@ -0,0 +1,255 @@
+'use client';
+
+import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
+import { Fragment, useEffect, useState } from 'react';
+import { BaseDocument } from '@/types/documents';
+
+interface DocumentSelectionModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: (selectedFiles: BaseDocument[]) => void;
+ title: string;
+ confirmLabel: string;
+ isProcessing: boolean;
+ defaultSelected?: boolean;
+ /**
+ * Data source:
+ * 1. `initialFiles`: Pass local files directly (synchronous).
+ * 2. `fetcher`: Pass an async function to load files (e.g. from server).
+ */
+ initialFiles?: BaseDocument[];
+ fetcher?: () => Promise
;
+}
+
+export function DocumentSelectionModal({
+ isOpen,
+ onClose,
+ onConfirm,
+ title,
+ confirmLabel,
+ isProcessing,
+ defaultSelected = false,
+ initialFiles,
+ fetcher,
+}: DocumentSelectionModalProps) {
+ const [files, setFiles] = useState([]);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [isLoading, setIsLoading] = useState(false);
+ const [lastSelectedId, setLastSelectedId] = useState(null);
+
+ useEffect(() => {
+ if (isOpen) {
+ if (initialFiles) {
+ setFiles(initialFiles);
+ if (defaultSelected) {
+ setSelectedIds(new Set(initialFiles.map((f) => f.id)));
+ } else {
+ setSelectedIds(new Set());
+ }
+ setLastSelectedId(null);
+ } else if (fetcher) {
+ setIsLoading(true);
+ fetcher()
+ .then((data) => {
+ setFiles(data);
+ if (defaultSelected) {
+ setSelectedIds(new Set(data.map((f) => f.id)));
+ } else {
+ setSelectedIds(new Set());
+ }
+ setLastSelectedId(null);
+ })
+ .catch((err) => console.error('Failed to load documents:', err))
+ .finally(() => setIsLoading(false));
+ } else {
+ setFiles([]);
+ setSelectedIds(new Set());
+ }
+ }
+ }, [isOpen, initialFiles, fetcher, defaultSelected]);
+
+ const toggleSelection = (id: string, multiSelect: boolean, rangeSelect: boolean) => {
+ const newSelected = new Set(multiSelect ? selectedIds : []);
+
+ if (rangeSelect && lastSelectedId && files.some((f) => f.id === lastSelectedId)) {
+ const lastIndex = files.findIndex((f) => f.id === lastSelectedId);
+ const currentIndex = files.findIndex((f) => f.id === id);
+ const start = Math.min(lastIndex, currentIndex);
+ const end = Math.max(lastIndex, currentIndex);
+
+ for (let i = start; i <= end; i++) {
+ newSelected.add(files[i].id);
+ }
+ } else {
+ if (newSelected.has(id)) {
+ newSelected.delete(id);
+ } else {
+ newSelected.add(id);
+ }
+ }
+
+ setSelectedIds(newSelected);
+ setLastSelectedId(id);
+ };
+
+ const handleRowClick = (e: React.MouseEvent, id: string) => {
+ if (e.metaKey || e.ctrlKey) {
+ toggleSelection(id, true, false);
+ } else if (e.shiftKey) {
+ toggleSelection(id, true, true);
+ } else {
+ // "Finder" behavior: Click selects only this one (unless multiselect modifier used)
+ // Checkbox click is handled separately to allow toggling without clearing
+ const newSelected = new Set();
+ newSelected.add(id);
+ setSelectedIds(newSelected);
+ setLastSelectedId(id);
+ }
+ };
+
+ const handleCheckboxChange = (id: string, checked: boolean) => {
+ const newSelected = new Set(selectedIds);
+ if (checked) newSelected.add(id);
+ else newSelected.delete(id);
+ setSelectedIds(newSelected);
+ setLastSelectedId(id);
+ };
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedIds(new Set(files.map(f => f.id)));
+ } else {
+ setSelectedIds(new Set());
+ }
+ };
+
+ const selectedCount = selectedIds.size;
+ const allSelected = files.length > 0 && selectedCount === files.length;
+ const isIndeterminate = selectedCount > 0 && selectedCount < files.length;
+
+ const handleConfirmClick = () => {
+ const selectedFiles = files.filter((f) => selectedIds.has(f.id));
+ onConfirm(selectedFiles);
+ };
+
+ const formatSize = (bytes: number) => {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+ {files.length > 0 && (
+
+
+ {
+ if (input) input.indeterminate = isIndeterminate;
+ }}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ />
+ Select All
+
+
+ )}
+
+
+
+ {isLoading ? (
+
Loading documents...
+ ) : files.length === 0 ? (
+
No documents found.
+ ) : (
+
+ {files.map((file) => {
+ const isSelected = selectedIds.has(file.id);
+ return (
+
handleRowClick(e, file.id)}
+ className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none
+ ${isSelected ? 'bg-accent/10' : 'hover:bg-offbase'}
+ `}
+ >
+
e.stopPropagation()}>
+ handleCheckboxChange(file.id, e.target.checked)}
+ className="rounded border-muted text-accent focus:ring-accent"
+ />
+
+
+ {file.name}
+
+
{formatSize(file.size)}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
+
+
+
+
+
+
+
+
+ );
+}
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`
}
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({
);
-}
\ No newline at end of file
+}
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index ea5a770..dc25e1f 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -22,13 +22,15 @@ 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 { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie';
import { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { THEMES } from '@/contexts/ThemeContext';
import { deleteServerDocuments } from '@/lib/client';
+import { DocumentSelectionModal } from '@/components/DocumentSelectionModal';
+import { BaseDocument } from '@/types/documents';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@@ -51,9 +53,25 @@ export function SettingsModal() {
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
const [isSyncing, setIsSyncing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
+ const [isImportingLibrary, setIsImportingLibrary] = useState(false);
+ const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false);
+ const [selectionModalProps, setSelectionModalProps] = useState<{
+ title: string;
+ confirmLabel: string;
+ mode: 'library' | 'load' | 'save';
+ defaultSelected: boolean;
+ initialFiles?: BaseDocument[];
+ fetcher?: () => Promise
;
+ }>({
+ title: '',
+ confirmLabel: '',
+ mode: 'library',
+ defaultSelected: 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(null);
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false);
@@ -149,68 +167,126 @@ export function SettingsModal() {
}, [modelValue, ttsModels]);
const handleSync = async () => {
- const controller = new AbortController();
- setAbortController(controller);
+ // Collect local documents
+ const pdfs = await getAllPdfDocuments();
+ const epubs = await getAllEpubDocuments();
+ const htmls = await getAllHtmlDocuments();
- try {
- setIsSyncing(true);
- setShowProgress(true);
- setProgress(0);
- setOperationType('sync');
- setStatusMessage('Preparing documents...');
- await syncDocumentsToServer((progress, status) => {
- if (controller.signal.aborted) return;
- setProgress(progress);
- if (status) setStatusMessage(status);
- }, controller.signal);
- } catch (error) {
- if (controller.signal.aborted) {
- console.log('Sync operation cancelled');
- setStatusMessage('Operation cancelled');
- } else {
- console.error('Sync failed:', error);
- setStatusMessage('Sync failed. Please try again.');
- }
- } finally {
- setIsSyncing(false);
- setShowProgress(false);
- setProgress(0);
- setStatusMessage('');
- setAbortController(null);
- }
+ const allDocs: BaseDocument[] = [
+ ...pdfs.map(d => ({ ...d, type: 'pdf' as const })),
+ ...epubs.map(d => ({ ...d, type: 'epub' as const })),
+ ...htmls.map(d => ({ ...d, type: 'html' as const }))
+ ];
+
+ setSelectionModalProps({
+ title: 'Save to Server',
+ confirmLabel: 'Save',
+ mode: 'save',
+ defaultSelected: true,
+ initialFiles: allDocs
+ });
+ setIsSelectionModalOpen(true);
};
const handleLoad = async () => {
+ setSelectionModalProps({
+ title: 'Load from Server',
+ confirmLabel: 'Load',
+ mode: 'load',
+ defaultSelected: true,
+ fetcher: async () => {
+ const res = await fetch('/api/documents?list=true');
+ if (!res.ok) throw new Error('Failed to list server documents');
+ const data = await res.json();
+ // Handle case where API might return error object
+ if (data.error) throw new Error(data.error);
+ return data.documents || [];
+ }
+ });
+ setIsSelectionModalOpen(true);
+ };
+
+ const handleImportLibrary = async () => {
+ setSelectionModalProps({
+ title: 'Import from Library',
+ confirmLabel: 'Import',
+ mode: 'library',
+ defaultSelected: false,
+ fetcher: async () => {
+ const res = await fetch('/api/documents/library?limit=10000');
+ if (!res.ok) throw new Error('Failed to list library documents');
+ const data = await res.json();
+ return data.documents || [];
+ }
+ });
+ setIsSelectionModalOpen(true);
+ };
+
+ const handleModalConfirm = async (selectedFiles: BaseDocument[]) => {
const controller = new AbortController();
setAbortController(controller);
+ const mode = selectionModalProps.mode;
+
+ // Close modal? Maybe keep open until started?
+ // Let's close it here, process starts.
+ // Actually we keep it open if we want to show loading state INSIDE modal?
+ // But existing UI uses a separate ProgressPopup.
+ // So close modal, show popup.
+ setIsSelectionModalOpen(false);
+
try {
- setIsLoading(true);
- setShowProgress(true);
- setProgress(0);
- setOperationType('load');
- setStatusMessage('Downloading documents from server...');
- await loadDocumentsFromServer((progress, status) => {
- if (controller.signal.aborted) return;
- setProgress(progress);
- if (status) setStatusMessage(status);
- }, controller.signal);
- if (controller.signal.aborted) return;
- setStatusMessage('Documents loaded from server');
+ setShowProgress(true);
+ setProgress(0);
+
+ if (mode === 'save') {
+ setIsSyncing(true);
+ setOperationType('sync');
+ setStatusMessage('Preparing documents...');
+ await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => {
+ if (controller.signal.aborted) return;
+ setProgress(progress);
+ if (status) setStatusMessage(status);
+ }, controller.signal);
+ } else if (mode === 'load') {
+ setIsLoading(true);
+ setOperationType('load');
+ setStatusMessage('Downloading documents...');
+ // Need ids
+ const ids = selectedFiles.map(f => f.id);
+ await loadSelectedDocumentsFromServer(ids, (progress, status) => {
+ if (controller.signal.aborted) return;
+ setProgress(progress);
+ if (status) setStatusMessage(status);
+ }, controller.signal);
+ if (!controller.signal.aborted) setStatusMessage('Documents loaded');
+ } else if (mode === 'library') {
+ setIsImportingLibrary(true);
+ setOperationType('library');
+ setStatusMessage('Importing selected documents...');
+ await importSelectedDocuments(selectedFiles, (progress, status) => {
+ if (controller.signal.aborted) return;
+ setProgress(progress);
+ if (status) setStatusMessage(status);
+ }, controller.signal);
+ }
+
} catch (error) {
- if (controller.signal.aborted) {
- console.log('Load operation cancelled');
- setStatusMessage('Operation cancelled');
- } else {
- console.error('Load failed:', error);
- setStatusMessage('Load failed. Please try again.');
- }
+ if (controller.signal.aborted) {
+ console.log(`${mode} operation cancelled`);
+ setStatusMessage('Operation cancelled');
+ } else {
+ console.error(`${mode} failed:`, error);
+ setStatusMessage(`${mode} failed. Please try again.`);
+ }
} finally {
- setIsLoading(false);
- setShowProgress(false);
- setProgress(0);
- setStatusMessage('');
- setAbortController(null);
+ setIsSyncing(false);
+ setIsLoading(false);
+ setIsImportingLibrary(false);
+ setShowProgress(false);
+ setProgress(0);
+ setStatusMessage('');
+ setAbortController(null);
}
};
@@ -598,12 +674,29 @@ export function SettingsModal() {
+
+
Server Library Import
+
+
+ {isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
+
+
+
+
{isDev &&
Server Document Sync
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 +734,7 @@ export function SettingsModal() {
{isDev && 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 +786,7 @@ export function SettingsModal() {
setProgress(0);
setIsSyncing(false);
setIsLoading(false);
+ setIsImportingLibrary(false);
setStatusMessage('');
setOperationType('sync');
setAbortController(null);
@@ -699,6 +795,17 @@ export function SettingsModal() {
operationType={operationType}
cancelText="Cancel"
/>
+ !isImportingLibrary && !isSyncing && !isLoading && setIsSelectionModalOpen(false)}
+ onConfirm={handleModalConfirm}
+ title={selectionModalProps.title}
+ confirmLabel={selectionModalProps.confirmLabel}
+ isProcessing={false} // Processing happens in ProgressPopup after closing
+ defaultSelected={selectionModalProps.defaultSelected}
+ initialFiles={selectionModalProps.initialFiles}
+ fetcher={selectionModalProps.fetcher}
+ />
>
);
}
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) => {
setLocalVoiceSpeed(parseFloat(event.target.value));
}, []);
- // Handler for audio player speed slider change (updates local state only)
const handleAudioSpeedChange = useCallback((event: React.ChangeEvent) => {
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 (
@@ -61,19 +60,20 @@ export const SpeedControl = ({
- {/* Native Model Speed */}
- {/* Audio Player Speed */}
Audio player speed
- 0.5x
- {Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x
- 3x
+ {min.toFixed(1)}x
+
+ {Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x
+
+ {max.toFixed(1)}x
);
-};
\ 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
([]);
-
- 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 (
-
- {(isKokoro && maxVoices > 1) ? (
-
{
- 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);
- }
- }}
- >
-
-
-
- {selectedVoices.length > 1
- ? selectedVoices.join(' + ')
- : selectedVoices[0] || currentVoice}
-
-
-
-
- {availableVoices.map((voiceId) => (
-
- `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' : ''}`
- }
- >
- {voiceId}
-
- ))}
-
-
- ) : (
-
-
-
- {currentVoice}
-
-
-
- {availableVoices.map((voiceId) => (
-
- `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' : ''}`
- }
- >
- {voiceId}
-
- ))}
-
-
- )}
-
+
);
-}
\ 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([]);
+
+ 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 (
+
+ );
+ }
+
+ return (
+
+ {isKokoro && maxVoices > 1 ? (
+
{
+ 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);
+ }
+ }}
+ >
+
+
+ {selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}
+
+
+
+ {availableVoices.map((voiceId) => (
+
+ `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' : ''}`
+ }
+ >
+ {voiceId}
+
+ ))}
+
+
+ ) : (
+
+
+
+ {currentVoice}
+
+
+
+ {availableVoices.map((voiceId) => (
+
+ `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' : ''}`
+ }
+ >
+ {voiceId}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
index 5089775..f8443a2 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(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,51 @@ 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.
+ const response = await fetch('/api/migrations/v1', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mappings }),
+ }).catch(() => null);
+
+ if (response?.ok) {
+ const data = await response.json();
+ const didMigrate =
+ data.documentsMigrated ||
+ data.audiobooksMigrated ||
+ (data.rekey?.renamed ?? 0) > 0 ||
+ (data.rekey?.merged ?? 0) > 0;
+
+ if (didMigrate) {
+ toast.success('Library migration complete', {
+ duration: 5000,
+ icon: '๐ฆ',
+ style: {
+ background: 'var(--offbase)',
+ color: 'var(--foreground)',
+ },
+ });
+ }
+ }
+ } 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;
clearCurrDoc: () => void;
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise;
- createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise;
- regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise;
+ createFullAudioBook: (
+ onProgress: (progress: number) => void,
+ signal?: AbortSignal,
+ onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
+ bookId?: string,
+ format?: TTSAudiobookFormat,
+ settings?: AudiobookGenerationSettings
+ ) => Promise;
+ regenerateChapter: (
+ chapterIndex: number,
+ bookId: string,
+ format: TTSAudiobookFormat,
+ signal: AbortSignal,
+ settings?: AudiobookGenerationSettings
+ ) => Promise;
bookRef: RefObject;
renditionRef: RefObject;
tocRef: RefObject;
@@ -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 => {
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 => {
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
) => void;
- createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise;
- regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise;
+ createFullAudioBook: (
+ onProgress: (progress: number) => void,
+ signal?: AbortSignal,
+ onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
+ bookId?: string,
+ format?: TTSAudiobookFormat,
+ settings?: AudiobookGenerationSettings
+ ) => Promise;
+ regenerateChapter: (
+ chapterIndex: number,
+ bookId: string,
+ format: TTSAudiobookFormat,
+ signal: AbortSignal,
+ settings?: AudiobookGenerationSettings
+ ) => Promise;
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 => {
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 => {
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 => {
- 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 => {
- 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 => {
- 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..e43d39e 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;
[APP_CONFIG_TABLE]: EntityTable;
[LAST_LOCATION_TABLE]: EntityTable;
+ [DOCUMENT_ID_MAP_TABLE]: EntityTable;
};
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): 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 = {
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 {
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(operation: () => Promise): Promise {
return operation();
}
+function isSha256HexId(value: string): boolean {
+ return /^[a-f0-9]{64}$/i.test(value);
+}
+
+async function getMappedDocumentId(docId: string): Promise {
+ 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 {
+ return withDB(async () => getMappedDocumentId(docId));
+}
+
+async function recordDocumentIdMapping(oldId: string, id: string): Promise {
+ if (oldId === id) return;
+ await db[DOCUMENT_ID_MAP_TABLE].put({ oldId, id, createdAt: Date.now() });
+}
+
+function rewriteDocumentListStateDocIds(state: DocumentListState, mapping: Map): DocumentListState {
+ let didChange = false;
+
+ const folders = state.folders.map((folder) => {
+ let folderChanged = false;
+ const seen = new Set();
+ 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 {
+ 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> {
+ 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> {
+ 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 {
@@ -228,7 +463,8 @@ export async function addPdfDocument(document: PDFDocument): Promise {
export async function getPdfDocument(id: string): Promise {
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 {
export async function removePdfDocument(id: string): Promise {
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 {
export async function getEpubDocument(id: string): Promise {
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 {
export async function removeEpubDocument(id: string): Promise {
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 {
export async function getHtmlDocument(id: string): Promise {
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 {
export async function removeHtmlDocument(id: string): Promise {
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
export async function getLastDocumentLocation(docId: string): Promise {
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 {
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!');
}
@@ -478,6 +731,67 @@ export async function syncDocumentsToServer(
return { lastSync: Date.now() };
}
+export async function syncSelectedDocumentsToServer(
+ documents: BaseDocument[],
+ onProgress?: (progress: number, status?: string) => void,
+ signal?: AbortSignal,
+): Promise<{ lastSync: number }> {
+ // Re-use logic from syncDocumentsToServer but only for specific documents
+ // Actually, syncDocumentsToServer fetches all docs from DB.
+ // We need to fetch the *full content* of the selected docs from DB.
+
+ const fullDocs: SyncedDocument[] = [];
+ let processed = 0;
+
+ for (const doc of documents) {
+ if (doc.type === 'pdf') {
+ const data = await getPdfDocument(doc.id);
+ if (data) fullDocs.push({ ...data, type: 'pdf', data: Array.from(new Uint8Array(data.data)) });
+ } else if (doc.type === 'epub') {
+ const data = await getEpubDocument(doc.id);
+ if (data) fullDocs.push({ ...data, type: 'epub', data: Array.from(new Uint8Array(data.data)) });
+ } else {
+ const data = await getHtmlDocument(doc.id);
+ if (data) {
+ const encoder = new TextEncoder();
+ fullDocs.push({ ...data, type: 'html', data: Array.from(encoder.encode(data.data)) });
+ }
+ }
+ processed++;
+ if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`);
+ }
+
+ if (onProgress) onProgress(50, 'Uploading to server...');
+
+ const response = await fetch('/api/documents', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ documents: fullDocs }),
+ signal,
+ });
+
+ if (!response.ok) {
+ 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!');
+ }
+
+ return { lastSync: Date.now() };
+}
+
+
export async function loadDocumentsFromServer(
onProgress?: (progress: number, status?: string) => void,
signal?: AbortSignal,
@@ -501,6 +815,52 @@ export async function loadDocumentsFromServer(
onProgress(40, 'Parsing documents...');
}
+ await saveSyncedDocumentsLocally(documents, onProgress);
+
+ if (onProgress) {
+ onProgress(100, 'Load complete!');
+ }
+
+ return { lastSync: Date.now() };
+}
+
+export async function loadSelectedDocumentsFromServer(
+ selectedIds: string[],
+ onProgress?: (progress: number, status?: string) => void,
+ signal?: AbortSignal,
+): Promise<{ lastSync: number }> {
+ if (onProgress) {
+ onProgress(10, 'Starting download...');
+ }
+
+ // Use new filtered API
+ const idsParam = selectedIds.join(',');
+ const response = await fetch(`/api/documents?ids=${encodeURIComponent(idsParam)}`, { signal });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch documents from server');
+ }
+
+ if (onProgress) {
+ onProgress(30, 'Download complete');
+ }
+
+ const { documents } = (await response.json()) as { documents: SyncedDocument[] };
+
+ if (onProgress) {
+ onProgress(40, 'Parsing documents...');
+ }
+
+ await saveSyncedDocumentsLocally(documents, onProgress);
+
+ if (onProgress) {
+ onProgress(100, 'Load complete!');
+ }
+
+ return { lastSync: Date.now() };
+}
+
+async function saveSyncedDocumentsLocally(documents: SyncedDocument[], onProgress?: (progress: number, status?: string) => void) {
const textDecoder = new TextDecoder();
for (let i = 0; i < documents.length; i++) {
@@ -548,10 +908,104 @@ export async function loadDocumentsFromServer(
onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`);
}
}
+}
+
+export async function importSelectedDocuments(
+ documents: BaseDocument[],
+ onProgress?: (progress: number, status?: string) => void,
+ signal?: AbortSignal,
+): Promise {
+ if (documents.length === 0) return;
+
+ 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}`);
+ }
+ }
+}
+
+export async function importDocumentsFromLibrary(
+ onProgress?: (progress: number, status?: string) => void,
+ signal?: AbortSignal,
+): Promise {
if (onProgress) {
- onProgress(100, 'Load complete!');
+ onProgress(5, 'Scanning server library...');
}
- return { lastSync: Date.now() };
+ 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...`);
+ }
+
+ await importSelectedDocuments(documents, onProgress, signal);
+
+ 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..86993b5
--- /dev/null
+++ b/src/lib/server/audiobook.ts
@@ -0,0 +1,224 @@
+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 escapeFFMetadata(value: string): string {
+ return value
+ .replace(/\\/g, '\\\\')
+ .replace(/=/g, '\\=')
+ .replace(/;/g, '\\;')
+ .replace(/#/g, '\\#')
+ .replace(/\r|\n/g, ' ');
+}
+
+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}`;
+}
+
+export 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 {
+ return new Promise((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 {
+ 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 {
+ 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..54910e7
--- /dev/null
+++ b/src/lib/server/docstore.ts
@@ -0,0 +1,555 @@
+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;
+ 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 {
+ try {
+ return JSON.parse(await readFile(MIGRATIONS_STATE_PATH, 'utf8')) as MigrationState;
+ } catch {
+ return {};
+ }
+}
+
+async function saveMigrationState(update: Partial): Promise {
+ 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 {
+ let entries: Array = [];
+ 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 {
+ 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 function getMigratedDocumentFileName(id: string, name: string): string {
+ const prefix = `${id}__`;
+ const encodedName = encodeURIComponent(name);
+ let targetFileName = `${prefix}${encodedName}`;
+
+ // Ensure total filename length is within safe limits (e.g. 240 chars).
+ // If too long, use a deterministic hash of the name instead of the full encoded name.
+ if (targetFileName.length > 240) {
+ const nameHash = createHash('sha256').update(name).digest('hex').slice(0, 32);
+ targetFileName = `${prefix}truncated-${nameHash}`;
+ }
+ return targetFileName;
+}
+
+export async function ensureDocumentsV1Ready(): Promise {
+ await mkdir(DOCSTORE_DIR, { recursive: true });
+ await mkdir(DOCUMENTS_V1_DIR, { recursive: true });
+
+ const state = await loadMigrationState();
+ if (state.documentsV1Migrated && !(await hasLegacyDocumentFiles())) {
+ return false;
+ }
+
+ if (!(await hasLegacyDocumentFiles())) {
+ await saveMigrationState({ documentsV1Migrated: true });
+ return false;
+ }
+
+ let entries: Array = [];
+ 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>;
+ 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 = getMigratedDocumentFileName(id, 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()) });
+ return true;
+}
+
+async function hasLegacyAudiobookDirs(): Promise {
+ let entries: Array = [];
+ 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 {
+ let entries: Array = [];
+ 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 {
+ 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 = [];
+ 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 {
+ 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;
+ const allowedKeys = new Set(['documentsV1Migrated', 'audiobooksV1Migrated', 'updatedAt']);
+ const hasExtraKeys = Object.keys(stateRaw).some((key) => !allowedKeys.has(key));
+ if (hasExtraKeys) {
+ await saveMigrationState({ audiobooksV1Migrated: true });
+ }
+ return false;
+ }
+
+ let entries: Array = [];
+ 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 });
+ return true;
+}
+
+type LegacyChapterMeta = {
+ title?: string;
+ duration?: number;
+ index?: number;
+ format?: string;
+};
+
+async function runProcess(command: string, args: string[]): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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();
+
+ 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') && file !== 'audiobook.meta.json') {
+ await unlink(path.join(intermediateDir, file)).catch(() => {});
+ }
+ }
+}
+
+async function normalizeAudiobookChapterLayout(): Promise {
+ let entries: Array = [];
+ 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..f4abc38
--- /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;
+}
+
+export 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 {
+ 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 {
+ return sha256HexFromBytes(new Uint8Array(buffer));
+}
+
+export async function sha256HexFromString(text: string): Promise {
+ 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/api.spec.ts b/tests/api.spec.ts
index dde7756..d7a7c61 100644
--- a/tests/api.spec.ts
+++ b/tests/api.spec.ts
@@ -8,13 +8,4 @@ test.describe('API health checks', () => {
expect(Array.isArray(json.voices)).toBeTruthy();
expect(json.voices.length).toBeGreaterThan(0);
});
-
- test('GET /api/audiobook/status returns 200 with exists flag and chapters array', async ({ request }) => {
- const bookId = `healthcheck-${Date.now()}`;
- const res = await request.get(`/api/audiobook/status?bookId=${bookId}`);
- expect(res.ok()).toBeTruthy();
- const json = await res.json();
- expect(json).toHaveProperty('exists');
- expect(Array.isArray(json.chapters)).toBeTruthy();
- });
});
\ No newline at end of file
diff --git a/tests/export.spec.ts b/tests/export.spec.ts
index 77324a7..2d3d727 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() || res.status() === 404).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/unit/audiobook.spec.ts b/tests/unit/audiobook.spec.ts
new file mode 100644
index 0000000..729de08
--- /dev/null
+++ b/tests/unit/audiobook.spec.ts
@@ -0,0 +1,97 @@
+import { test, expect } from '@playwright/test';
+import {
+ escapeFFMetadata,
+ encodeChapterTitleTag,
+ decodeChapterTitleTag,
+ encodeChapterFileName,
+ decodeChapterFileName
+} from '../../src/lib/server/audiobook';
+
+test.describe('escapeFFMetadata', () => {
+ test('escapes special characters correctly', () => {
+ const input = 'Title with = ; # and backslash \\';
+ // Expected: Equal -> \=, Semicolon -> \;, Hash -> \#, Backslash -> \\
+ const expected = 'Title with \\= \\; \\# and backslash \\\\';
+ expect(escapeFFMetadata(input)).toBe(expected);
+ });
+
+ test('normalizes newlines to spaces', () => {
+ const input = 'Title with\nnewline and\rreturn';
+ const expected = 'Title with newline and return';
+ expect(escapeFFMetadata(input)).toBe(expected);
+ });
+
+ test('handles mixed special characters and newlines', () => {
+ const input = 'Line1\nLine2=Value;Comment#';
+ const expected = 'Line1 Line2\\=Value\\;Comment\\#';
+ expect(escapeFFMetadata(input)).toBe(expected);
+ });
+
+ test('returns empty string as-is', () => {
+ expect(escapeFFMetadata('')).toBe('');
+ });
+
+ test('returns safe string as-is', () => {
+ const input = 'Safe Title 123';
+ expect(escapeFFMetadata(input)).toBe(input);
+ });
+});
+
+test.describe('Title Tags', () => {
+ test('encodeChapterTitleTag formats correctly', () => {
+ expect(encodeChapterTitleTag(0, 'Intro')).toBe('0001 - Intro');
+ expect(encodeChapterTitleTag(9, 'Chapter Ten')).toBe('0010 - Chapter Ten');
+ });
+
+ test('encodeChapterTitleTag sanitizes inputs', () => {
+ expect(encodeChapterTitleTag(0, 'Line\nBreak')).toBe('0001 - Line Break');
+ });
+
+ test('decodeChapterTitleTag parses correctly', () => {
+ expect(decodeChapterTitleTag('0001 - Intro')).toEqual({ index: 0, title: 'Intro' });
+ expect(decodeChapterTitleTag('10 - Chapter Ten')).toEqual({ index: 9, title: 'Chapter Ten' });
+ });
+
+ test('decodeChapterTitleTag handles flexible separators', () => {
+ expect(decodeChapterTitleTag('1: Intro')).toEqual({ index: 0, title: 'Intro' });
+ expect(decodeChapterTitleTag('1. Intro')).toEqual({ index: 0, title: 'Intro' });
+ });
+
+ test('decodeChapterTitleTag returns null for invalid input', () => {
+ expect(decodeChapterTitleTag('Not a chapter')).toBeNull();
+ expect(decodeChapterTitleTag('0 - Zero index invalid')).toBeNull();
+ });
+});
+
+test.describe('Chapter File Names', () => {
+ test('encodeChapterFileName formats correctly', () => {
+ expect(encodeChapterFileName(0, 'Intro', 'mp3')).toBe('0001__Intro.mp3');
+ expect(encodeChapterFileName(1, 'Part 2', 'm4b')).toBe('0002__Part%202.m4b');
+ });
+
+ test('encodeChapterFileName sanitizes dangerous characters', () => {
+ // slash should be replaced by space -> then encoded
+ expect(encodeChapterFileName(0, 'Ac/Dc', 'mp3')).toBe('0001__Ac%20Dc.mp3');
+ });
+
+ test('decodeChapterFileName parses correctly', () => {
+ expect(decodeChapterFileName('0001__Intro.mp3')).toEqual({ index: 0, title: 'Intro', format: 'mp3' });
+ expect(decodeChapterFileName('0002__Part%202.m4b')).toEqual({ index: 1, title: 'Part 2', format: 'm4b' });
+ });
+
+ test('decodeChapterFileName handles standard filenames without double-underscore', () => {
+ // The regex requires double underscore: /^(\d{1,6})__(.+)\.(mp3|m4b)$/i
+ expect(decodeChapterFileName('0001-chapter.mp3')).toBeNull();
+ });
+
+ test('round trip consistency', () => {
+ const index = 5;
+ const title = 'My Cool Chapter';
+ const format = 'mp3';
+
+ const encoded = encodeChapterFileName(index, title, format);
+ const decoded = decodeChapterFileName(encoded);
+
+ expect(decoded).toEqual({ index, title, format });
+ });
+});
diff --git a/tests/unit/docstore.spec.ts b/tests/unit/docstore.spec.ts
new file mode 100644
index 0000000..55a57e7
--- /dev/null
+++ b/tests/unit/docstore.spec.ts
@@ -0,0 +1,53 @@
+import { test, expect } from '@playwright/test';
+import { getMigratedDocumentFileName } from '../../src/lib/server/docstore';
+
+test.describe('Docstore Filename Safety', () => {
+ const id = 'a'.repeat(64); // Simulate sha256 hex ID
+
+ test('should generate standard filename for short names', () => {
+ const name = 'test-file.pdf';
+ const result = getMigratedDocumentFileName(id, name);
+ expect(result).toBe(`${id}__test-file.pdf`);
+ expect(result.length).toBeLessThanOrEqual(240);
+ });
+
+ test('should truncate very long names', () => {
+ const longName = 'a'.repeat(300);
+ const result = getMigratedDocumentFileName(id, longName);
+
+ expect(result.length).toBeLessThanOrEqual(240);
+ expect(result).toContain('truncated-');
+ expect(result.startsWith(`${id}__`)).toBeTruthy();
+ });
+
+ test('should truncate names that become too long after encoding', () => {
+ // Chinese characters take 9 chars each when encoded (%XX%XX%XX)
+ const specialName = '็นๆฎๅญ็ฌฆ'.repeat(30);
+ const result = getMigratedDocumentFileName(id, specialName);
+
+ expect(result.length).toBeLessThanOrEqual(240);
+ expect(result).toContain('truncated-');
+ // The implementation replaces the name with a hash, so it should NOT contain the original special chars
+ // and might not contain % if the hash is hex.
+ expect(result).toMatch(/truncated-[a-f0-9]{32}$/);
+ expect(result.startsWith(`${id}__`)).toBeTruthy();
+ });
+
+ test('should handle edge case length exactly', () => {
+ // Create a name that would result in exactly 241 chars to trigger truncation
+ // Prefix is 64 + 2 = 66 chars.
+ // Max allowed = 240.
+ // Available for name = 240 - 66 = 174.
+ // If we give 175 chars, it should truncate.
+ const name = 'a'.repeat(175);
+ const result = getMigratedDocumentFileName(id, name);
+ expect(result).toContain('truncated-');
+ });
+
+ test('should not truncate if exactly at limit', () => {
+ const name = 'a'.repeat(174);
+ const result = getMigratedDocumentFileName(id, name);
+ expect(result.length).toBe(240);
+ expect(result).not.toContain('truncated-');
+ });
+});
diff --git a/tests/unit/nlp.spec.ts b/tests/unit/nlp.spec.ts
new file mode 100644
index 0000000..4bb5485
--- /dev/null
+++ b/tests/unit/nlp.spec.ts
@@ -0,0 +1,88 @@
+import { test, expect } from '@playwright/test';
+import {
+ preprocessSentenceForAudio,
+ splitIntoSentences,
+ processTextWithMapping
+} from '../../src/lib/nlp';
+
+test.describe('preprocessSentenceForAudio', () => {
+ test('removes URLs', () => {
+ const input = 'Check out https://example.com/page for more info';
+ const expected = 'Check out - (link to example.com) - for more info';
+ expect(preprocessSentenceForAudio(input)).toBe(expected);
+ });
+
+ test('removes hyphenation', () => {
+ const input = 'This is a hyp- henated word';
+ const expected = 'This is a hyphenated word';
+ expect(preprocessSentenceForAudio(input)).toBe(expected);
+ });
+
+ test('removes asterisks', () => {
+ const input = 'This is *bold* text';
+ const expected = 'This is bold text';
+ expect(preprocessSentenceForAudio(input)).toBe(expected);
+ });
+
+ test('collapses whitespace', () => {
+ const input = 'Multiple spaces';
+ const expected = 'Multiple spaces';
+ expect(preprocessSentenceForAudio(input)).toBe(expected);
+ });
+});
+
+test.describe('splitIntoSentences', () => {
+ test('groups short sentences into single block', () => {
+ const input = 'First sentence. Second sentence.';
+ const result = splitIntoSentences(input);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('First sentence. Second sentence.');
+ });
+
+ test('merges quoted dialogue (double quotes)', () => {
+ const input = 'He said, "This should be one block." and walked away.';
+ const result = splitIntoSentences(input);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('He said, "This should be one block." and walked away.');
+ });
+
+ test('merges quoted dialogue (curly quotes)', () => {
+ const input = 'She replied, โThis also should be merged.โ then smiled.';
+ const result = splitIntoSentences(input);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('She replied, โThis also should be merged.โ then smiled.');
+ });
+
+ test('respects max block length for long text', () => {
+ // MAX_BLOCK_LENGTH is 450 in nlp.ts
+ // We construct distinct sentences.
+ // If we make sentences short enough individually but long enough combined,
+ // they should be grouped until the limit is reached.
+
+ const sentence = 'A'.repeat(100) + '.'; // 101 chars
+ // 4 sentences = 404 chars + 3 spaces = 407 chars (< 450). Should fit in one block.
+ // 5 sentences = 505 chars + 4 spaces = 509 chars (> 450). Should split.
+
+ const input = Array(5).fill(sentence).join(' ');
+ const result = splitIntoSentences(input);
+
+ expect(result.length).toBeGreaterThan(1);
+ // The first block should contain as many as possible
+ expect(result[0].length).toBeLessThanOrEqual(450);
+ });
+});
+
+test.describe('processTextWithMapping', () => {
+ test('maps raw sentences to processed ones', () => {
+ const text = 'First (1). Second (2).';
+ const { processedSentences, rawSentences, sentenceMapping } = processTextWithMapping(text);
+
+ expect(processedSentences.length).toBeGreaterThan(0);
+ expect(rawSentences.length).toBeGreaterThan(0);
+ expect(sentenceMapping).toHaveLength(processedSentences.length);
+
+ // Check structure of mapping
+ expect(sentenceMapping[0]).toHaveProperty('processedIndex');
+ expect(sentenceMapping[0]).toHaveProperty('rawIndices');
+ });
+});
diff --git a/tests/unit/sha256.spec.ts b/tests/unit/sha256.spec.ts
new file mode 100644
index 0000000..030122a
--- /dev/null
+++ b/tests/unit/sha256.spec.ts
@@ -0,0 +1,34 @@
+import { test, expect } from '@playwright/test';
+import {
+ sha256HexSoftwareFromBytes,
+ sha256HexFromString
+} from '../../src/lib/sha256';
+
+test.describe('SHA256 Software Implementation', () => {
+ // Known test vectors
+ // "" -> e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+ // "abc" -> ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
+
+ test('hashes empty string correctly', () => {
+ const input = new Uint8Array([]);
+ const expected = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
+ expect(sha256HexSoftwareFromBytes(input)).toBe(expected);
+ });
+
+ test('hashes "abc" correctly', () => {
+ const input = new TextEncoder().encode('abc');
+ const expected = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad';
+ expect(sha256HexSoftwareFromBytes(input)).toBe(expected);
+ });
+
+ test('matches WebCrypto/fallback output for long strings', async () => {
+ // Create a longer input
+ const text = 'a'.repeat(1000);
+ const input = new TextEncoder().encode(text);
+
+ const software = sha256HexSoftwareFromBytes(input);
+ const automatic = await sha256HexFromString(text);
+
+ expect(software).toBe(automatic);
+ });
+});
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((resolve, reject) => {
+ const request = indexedDB.open('openreader-db');
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+ });
+
+ try {
+ const docs = await new Promise((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');