Skip to content

Commit 4266588

Browse files
committed
feat(audiobook): add chapter-based export with UI and API
Introduce end-to-end chapterized audiobook generation with persistent storage, resumable workflows, and MP3/M4B support. API: - add /api/audio/convert/chapter (GET/DELETE) for per-chapter ops - add /api/audio/convert/chapters (GET/DELETE) for listing/reset - enhance /api/audio/convert: - accept mp3|m4b, stream combined file, cache complete output - robust chapter indexing, docstore persistence, list concat - AbortSignal-aware ffmpeg/ffprobe, 499 on cancel UI/UX: - add AudiobookExportModal with progress, resume, regenerate, download - add ProgressCard, enhance ProgressPopup (click-to-focus, richer info) - add Header, ZoomControl; move TTSPlayer to sticky bottom bar - redesign EPUB/HTML/PDF pages for full-height layout and controls - add HomeContent; compact uploader variant; document list polish TTS/Contexts: - EPUB/PDF contexts now generate per-chapter to disk, return bookId - support chapter regeneration; progress/cancel propagation - pass provider/model/instructions; standardize MP3 TTS output - HTML context updates for model/instructions Styling: - globals: overlay-dim, scrollbar styles, prism gradient utilities - theme vars: secondary-accent, prism-gradient; tailwind color addition Misc: - fix PDF scale calc to use container height - EPUB theme reader area fills height - time estimation update cadence stab - audio util passes format to combine endpoint
1 parent e7ce1a3 commit 4266588

39 files changed

+2767
-607
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createReadStream, existsSync } from 'fs';
3+
import { readFile, unlink } from 'fs/promises';
4+
import { join } from 'path';
5+
6+
export async function GET(request: NextRequest) {
7+
try {
8+
const bookId = request.nextUrl.searchParams.get('bookId');
9+
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
10+
11+
if (!bookId || !chapterIndexStr) {
12+
return NextResponse.json(
13+
{ error: 'Missing bookId or chapterIndex parameter' },
14+
{ status: 400 }
15+
);
16+
}
17+
18+
const chapterIndex = parseInt(chapterIndexStr);
19+
if (isNaN(chapterIndex)) {
20+
return NextResponse.json(
21+
{ error: 'Invalid chapterIndex parameter' },
22+
{ status: 400 }
23+
);
24+
}
25+
26+
const docstoreDir = join(process.cwd(), 'docstore');
27+
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
28+
29+
// Read metadata to get format
30+
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
31+
if (!existsSync(metadataPath)) {
32+
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
33+
}
34+
35+
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'));
36+
const format = metadata.format || 'm4b';
37+
const chapterPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
38+
39+
if (!existsSync(chapterPath)) {
40+
return NextResponse.json({ error: 'Chapter file not found' }, { status: 404 });
41+
}
42+
43+
// Stream the chapter file
44+
const stream = createReadStream(chapterPath);
45+
46+
const readableWebStream = new ReadableStream({
47+
start(controller) {
48+
stream.on('data', (chunk) => {
49+
controller.enqueue(chunk);
50+
});
51+
stream.on('end', () => {
52+
controller.close();
53+
});
54+
stream.on('error', (err) => {
55+
controller.error(err);
56+
});
57+
},
58+
cancel() {
59+
stream.destroy();
60+
}
61+
});
62+
63+
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
64+
const sanitizedTitle = metadata.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
65+
66+
return new NextResponse(readableWebStream, {
67+
headers: {
68+
'Content-Type': mimeType,
69+
'Content-Disposition': `attachment; filename="${sanitizedTitle}.${format}"`,
70+
'Cache-Control': 'no-cache',
71+
},
72+
});
73+
74+
} catch (error) {
75+
console.error('Error downloading chapter:', error);
76+
return NextResponse.json(
77+
{ error: 'Failed to download chapter' },
78+
{ status: 500 }
79+
);
80+
}
81+
}
82+
83+
export async function DELETE(request: NextRequest) {
84+
try {
85+
const bookId = request.nextUrl.searchParams.get('bookId');
86+
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
87+
88+
if (!bookId || !chapterIndexStr) {
89+
return NextResponse.json(
90+
{ error: 'Missing bookId or chapterIndex parameter' },
91+
{ status: 400 }
92+
);
93+
}
94+
95+
const chapterIndex = parseInt(chapterIndexStr, 10);
96+
if (isNaN(chapterIndex)) {
97+
return NextResponse.json(
98+
{ error: 'Invalid chapterIndex parameter' },
99+
{ status: 400 }
100+
);
101+
}
102+
103+
const docstoreDir = join(process.cwd(), 'docstore');
104+
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
105+
106+
// Read metadata to get format (if present)
107+
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
108+
109+
// Delete the chapter audio file (try both formats just in case)
110+
const chapterPathM4b = join(intermediateDir, `${chapterIndex}-chapter.m4b`);
111+
const chapterPathMp3 = join(intermediateDir, `${chapterIndex}-chapter.mp3`);
112+
if (existsSync(chapterPathM4b)) await unlink(chapterPathM4b).catch(() => {});
113+
if (existsSync(chapterPathMp3)) await unlink(chapterPathMp3).catch(() => {});
114+
115+
// Delete metadata if present
116+
if (existsSync(metadataPath)) {
117+
await unlink(metadataPath).catch(() => {});
118+
}
119+
120+
// Invalidate any combined "complete" files
121+
const completeM4b = join(intermediateDir, `complete.m4b`);
122+
const completeMp3 = join(intermediateDir, `complete.mp3`);
123+
if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {});
124+
if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {});
125+
126+
return NextResponse.json({ success: true });
127+
} catch (error) {
128+
console.error('Error deleting chapter:', error);
129+
return NextResponse.json(
130+
{ error: 'Failed to delete chapter' },
131+
{ status: 500 }
132+
);
133+
}
134+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { readdir, readFile, rm } from 'fs/promises';
3+
import { existsSync } from 'fs';
4+
import { join } from 'path';
5+
6+
export async function GET(request: NextRequest) {
7+
try {
8+
const bookId = request.nextUrl.searchParams.get('bookId');
9+
if (!bookId) {
10+
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
11+
}
12+
13+
const docstoreDir = join(process.cwd(), 'docstore');
14+
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
15+
16+
if (!existsSync(intermediateDir)) {
17+
return NextResponse.json({ chapters: [], exists: false });
18+
}
19+
20+
// Read all chapter metadata
21+
const files = await readdir(intermediateDir);
22+
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
23+
const chapters: Array<{
24+
index: number;
25+
title: string;
26+
duration?: number;
27+
status: 'completed' | 'error';
28+
bookId: string;
29+
format?: 'mp3' | 'm4b';
30+
}> = [];
31+
32+
for (const metaFile of metaFiles) {
33+
try {
34+
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
35+
chapters.push({
36+
index: meta.index,
37+
title: meta.title,
38+
duration: meta.duration,
39+
status: 'completed',
40+
bookId,
41+
format: meta.format || 'm4b'
42+
});
43+
} catch (error) {
44+
console.error(`Error reading metadata file ${metaFile}:`, error);
45+
}
46+
}
47+
48+
// Sort chapters by index
49+
chapters.sort((a, b) => a.index - b.index);
50+
51+
// Check if complete audiobook exists (either format)
52+
const format = chapters[0]?.format || 'm4b';
53+
const completePath = join(intermediateDir, `complete.${format}`);
54+
const hasComplete = existsSync(completePath);
55+
56+
return NextResponse.json({
57+
chapters,
58+
exists: true,
59+
hasComplete,
60+
bookId
61+
});
62+
63+
} catch (error) {
64+
console.error('Error fetching chapters:', error);
65+
return NextResponse.json(
66+
{ error: 'Failed to fetch chapters' },
67+
{ status: 500 }
68+
);
69+
}
70+
}
71+
72+
export async function DELETE(request: NextRequest) {
73+
try {
74+
const bookId = request.nextUrl.searchParams.get('bookId');
75+
if (!bookId) {
76+
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
77+
}
78+
79+
const docstoreDir = join(process.cwd(), 'docstore');
80+
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
81+
82+
// If directory doesn't exist, consider it already reset
83+
if (!existsSync(intermediateDir)) {
84+
return NextResponse.json({ success: true, existed: false });
85+
}
86+
87+
// Recursively delete the entire audiobook directory
88+
await rm(intermediateDir, { recursive: true, force: true });
89+
90+
return NextResponse.json({ success: true, existed: true });
91+
} catch (error) {
92+
console.error('Error resetting audiobook:', error);
93+
return NextResponse.json(
94+
{ error: 'Failed to reset audiobook' },
95+
{ status: 500 }
96+
);
97+
}
98+
}

0 commit comments

Comments
 (0)