perf: skip ffmpeg transcode for PocketTTS streaming — use WAV directly
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / ui (push) Successful in 2m19s
Release / Gitea Release (push) Successful in 36s

PocketTTS emits 16-bit PCM WAV (16 kHz mono). WAV is natively supported
on all browsers including iOS/macOS Safari, so the ffmpeg MP3 transcode
is unnecessary for the streaming path.

Using format=wav for PocketTTS voices eliminates the ffmpeg subprocess
startup delay (~200–400 ms) and a pipeline stage, giving lower latency
to first audio frame. Kokoro and CF AI continue using MP3 (they output
MP3 natively or via the OpenAI-compatible endpoint).

The runner (MinIO storage) is unaffected — it still stores MP3 for
space efficiency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-04 19:55:05 +05:00
parent 25150c2284
commit 6456e8cf5d
29 changed files with 1580 additions and 52 deletions

View File

@@ -0,0 +1,34 @@
/**
* POST /api/admin/audio/bulk
*
* Admin-only proxy to the Go backend's audio bulk-enqueue endpoint.
* Body: { slug, voice?, from, to, skip_existing?, force? }
* Response 202: { enqueued, skipped, task_ids }
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/audio/bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/audio/bulk', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,34 @@
/**
* POST /api/admin/audio/cancel-bulk
*
* Admin-only proxy to cancel all pending/running audio tasks for a slug.
* Body: { slug }
* Response 200: { cancelled }
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/audio/cancel-bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/audio/cancel-bulk', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -133,6 +133,301 @@
// ── Admin panel expand/collapse ───────────────────────────────────────────
let adminOpen = $state(false);
// ── Admin: book cover generation ──────────────────────────────────────────
let coverGenerating = $state(false);
let coverPreview = $state<string | null>(null);
let coverSaving = $state(false);
let coverResult = $state<'saved' | 'error' | ''>('');
async function generateCover() {
const slug = data.book?.slug;
if (coverGenerating || !slug) return;
coverGenerating = true;
coverPreview = null;
coverResult = '';
try {
const res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
});
if (res.ok) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
coverResult = 'error';
}
} catch {
coverResult = 'error';
} finally {
coverGenerating = false;
}
}
async function saveCover() {
const slug = data.book?.slug;
if (coverSaving || !coverPreview || !slug) return;
coverSaving = true;
coverResult = '';
try {
const b64 = coverPreview.replace(/^data:[^;]+;base64,/, '');
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, image_b64: b64 })
});
if (res.ok) {
coverResult = 'saved';
coverPreview = null;
await invalidateAll();
} else {
coverResult = 'error';
}
} catch {
coverResult = 'error';
} finally {
coverSaving = false;
}
}
// ── Admin: chapter cover generation ───────────────────────────────────────
let chapterCoverN = $state('1');
let chapterCoverGenerating = $state(false);
let chapterCoverPreview = $state<string | null>(null);
let chapterCoverResult = $state<'error' | ''>('');
async function generateChapterCover() {
const slug = data.book?.slug;
if (chapterCoverGenerating || !slug) return;
const n = parseInt(chapterCoverN, 10);
if (!n || n < 1) return;
chapterCoverGenerating = true;
chapterCoverPreview = null;
chapterCoverResult = '';
try {
const res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: data.book?.title ?? slug })
});
if (res.ok) {
const d = await res.json();
chapterCoverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
chapterCoverResult = 'error';
}
} catch {
chapterCoverResult = 'error';
} finally {
chapterCoverGenerating = false;
}
}
// ── Admin: description generation ─────────────────────────────────────────
let descGenerating = $state(false);
let descPreview = $state('');
let descApplying = $state(false);
let descResult = $state<'applied' | 'error' | ''>('');
async function generateDesc() {
const slug = data.book?.slug;
if (descGenerating || !slug) return;
descGenerating = true;
descPreview = '';
descResult = '';
try {
const res = await fetch('/api/admin/text-gen/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
});
if (res.ok) {
const d = await res.json();
descPreview = d.new_description ?? '';
} else {
descResult = 'error';
}
} catch {
descResult = 'error';
} finally {
descGenerating = false;
}
}
async function applyDesc() {
const slug = data.book?.slug;
if (descApplying || !descPreview || !slug) return;
descApplying = true;
descResult = '';
try {
const res = await fetch('/api/admin/text-gen/description/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, description: descPreview })
});
if (res.ok) {
descResult = 'applied';
descPreview = '';
await invalidateAll();
} else {
descResult = 'error';
}
} catch {
descResult = 'error';
} finally {
descApplying = false;
}
}
// ── Admin: chapter names generation ───────────────────────────────────────
let chapNamesGenerating = $state(false);
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string }[]>([]);
let chapNamesApplying = $state(false);
let chapNamesResult = $state<'applied' | 'error' | ''>('');
async function generateChapNames() {
const slug = data.book?.slug;
if (chapNamesGenerating || !slug) return;
chapNamesGenerating = true;
chapNamesPreview = [];
chapNamesResult = '';
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, pattern: 'Chapter {n}: {scene}' })
});
if (res.ok) {
const d = await res.json();
chapNamesPreview = d.chapters ?? [];
} else {
chapNamesResult = 'error';
}
} catch {
chapNamesResult = 'error';
} finally {
chapNamesGenerating = false;
}
}
async function applyChapNames() {
const slug = data.book?.slug;
if (chapNamesApplying || chapNamesPreview.length === 0 || !slug) return;
chapNamesApplying = true;
chapNamesResult = '';
try {
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.new_title }));
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, chapters })
});
if (res.ok) {
chapNamesResult = 'applied';
chapNamesPreview = [];
await invalidateAll();
} else {
chapNamesResult = 'error';
}
} catch {
chapNamesResult = 'error';
} finally {
chapNamesApplying = false;
}
}
// ── Admin: audio TTS bulk enqueue ─────────────────────────────────────────
interface Voice { id: string; engine: string; lang: string; gender: string }
let audioVoices = $state<Voice[]>([]);
let audioVoicesLoaded = $state(false);
let audioVoice = $state('af_bella');
let audioFrom = $state('1');
let audioTo = $state('');
let audioEnqueuing = $state(false);
let audioResult = $state<{ enqueued: number; skipped: number } | null>(null);
let audioError = $state('');
// Load voices lazily when admin panel opens
$effect(() => {
if (!adminOpen || audioVoicesLoaded) return;
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => {
audioVoices = d.voices ?? [];
audioVoicesLoaded = true;
})
.catch(() => { audioVoicesLoaded = true; });
});
function voiceLabel(v: Voice): string {
if (v.engine === 'cfai') {
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + ' (CF AI)';
}
if (v.engine === 'pocket-tts') {
return v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + ' (Pocket)';
}
// Kokoro
const langMap: Record<string, string> = {
af: 'US', am: 'US', bf: 'UK', bm: 'UK',
ef: 'ES', em: 'ES', ff: 'FR', hf: 'IN', hm: 'IN',
'if': 'IT', im: 'IT', jf: 'JP', jm: 'JP', pf: 'PT', pm: 'PT', zf: 'ZH', zm: 'ZH',
};
const prefix = v.id.slice(0, 2);
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
const lang = langMap[prefix] ?? prefix.toUpperCase();
return `${name} (${lang})`;
}
async function enqueueAudio() {
const slug = data.book?.slug;
if (audioEnqueuing || !slug) return;
const from = parseInt(audioFrom, 10);
const to = audioTo ? parseInt(audioTo, 10) : (data.book?.total_chapters ?? 1);
if (!from || from < 1) return;
audioEnqueuing = true;
audioResult = null;
audioError = '';
try {
const res = await fetch('/api/admin/audio/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, voice: audioVoice, from, to })
});
if (res.ok || res.status === 202) {
const d = await res.json();
audioResult = { enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
} else {
const d = await res.json().catch(() => ({}));
audioError = d.error ?? 'Failed to enqueue';
}
} catch {
audioError = 'Network error';
} finally {
audioEnqueuing = false;
}
}
async function cancelAudio() {
const slug = data.book?.slug;
if (!slug) return;
audioResult = null;
audioError = '';
try {
const res = await fetch('/api/admin/audio/cancel-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
});
if (res.ok) {
const d = await res.json();
audioError = `Cancelled ${d.cancelled ?? 0} task(s).`;
}
} catch {
audioError = 'Cancel failed';
}
}
// ── "More like this" ─────────────────────────────────────────────────────
interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null }
let similarBooks = $state<SimilarBook[]>([]);
@@ -520,78 +815,336 @@
</svg>
</button>
{#if adminOpen}
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-4">
<!-- Rescrape -->
{#if adminOpen}
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-5">
<!-- Rescrape -->
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={rescrape}
disabled={scraping}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{m.book_detail_rescraping()}
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{m.book_detail_rescrape_book()}
{/if}
</button>
{#if scrapeResult}
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{/if}
</div>
<!-- Range scrape -->
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
<input
id="range-to"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<button
onclick={scrapeRange}
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
>
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{/if}
</div>
<hr class="border-(--color-border)" />
<!-- Book cover generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={rescrape}
disabled={scraping}
onclick={generateCover}
disabled={coverGenerating}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
{coverGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{m.book_detail_rescraping()}
{#if coverGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{m.book_detail_rescrape_book()}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if scrapeResult}
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{#if coverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if coverResult === 'saved'}
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
{/if}
</div>
<!-- Range scrape -->
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
{#if coverPreview}
<div class="flex items-start gap-3 mt-1">
<img src={coverPreview} alt="Cover preview" class="w-24 rounded border border-(--color-border)" />
<div class="flex flex-col gap-2 pt-1">
<button
onclick={saveCover}
disabled={coverSaving}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{coverSaving ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
>
{coverSaving ? m.book_detail_admin_saving() : m.book_detail_admin_save_cover()}
</button>
<button onclick={() => (coverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
</div>
{/if}
</div>
<!-- Chapter cover generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
<div class="flex items-end gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
<input
id="range-to"
id="ch-cover-n"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
bind:value={chapterCoverN}
placeholder="1"
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<button
onclick={scrapeRange}
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
onclick={generateChapterCover}
disabled={chapterCoverGenerating || !chapterCoverN}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{chapterCoverGenerating || !chapterCoverN ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
{#if chapterCoverGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{#if chapterCoverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{/if}
</div>
{#if chapterCoverPreview}
<div class="flex items-start gap-3 mt-1">
<img src={chapterCoverPreview} alt="Chapter cover preview" class="w-24 rounded border border-(--color-border)" />
<button onclick={() => (chapterCoverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors pt-1">{m.book_detail_admin_discard()}</button>
</div>
{/if}
</div>
<hr class="border-(--color-border)" />
<!-- Description generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateDesc}
disabled={descGenerating}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{descGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{#if descGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if descResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if descResult === 'applied'}
<span class="text-xs text-green-400">{m.book_detail_admin_applied()}</span>
{/if}
</div>
{#if descPreview}
<div class="flex flex-col gap-2">
<textarea
bind:value={descPreview}
rows="5"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
></textarea>
<div class="flex items-center gap-2">
<button
onclick={applyDesc}
disabled={descApplying}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{descApplying ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
>
{descApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()}
</button>
<button onclick={() => (descPreview = '')} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
</div>
{/if}
</div>
<!-- Chapter names generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateChapNames}
disabled={chapNamesGenerating}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{chapNamesGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{#if chapNamesGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/></svg>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if chapNamesResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if chapNamesResult === 'applied'}
<span class="text-xs text-green-400">{m.book_detail_admin_applied()} ({chapNamesPreview.length > 0 ? chapNamesPreview.length : ''})</span>
{/if}
</div>
{#if chapNamesPreview.length > 0}
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
{#each chapNamesPreview as ch}
<div class="flex gap-2 text-xs">
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
<span class="text-(--color-text) truncate">{ch.new_title}</span>
</div>
{/each}
</div>
<div class="flex items-center gap-2">
<button
onclick={applyChapNames}
disabled={chapNamesApplying}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{chapNamesApplying ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
>
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
</button>
<button onclick={() => (chapNamesPreview = [])} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
{/if}
</div>
<hr class="border-(--color-border)" />
<!-- Audio TTS bulk enqueue -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_audio_tts()}</p>
<div class="flex flex-col gap-3">
<!-- Voice selector -->
<div class="flex flex-col gap-1">
<label for="audio-voice" class="text-xs text-(--color-muted)">{m.book_detail_admin_voice()}</label>
<select
id="audio-voice"
bind:value={audioVoice}
class="px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
>
{#if !audioVoicesLoaded}
<option value="af_bella">af_bella (loading…)</option>
{:else}
{#each audioVoices.filter(v => v.engine === 'kokoro') as v}
<option value={v.id}>{voiceLabel(v)}</option>
{/each}
{#each audioVoices.filter(v => v.engine === 'pocket-tts') as v}
<option value={v.id}>{voiceLabel(v)}</option>
{/each}
{#each audioVoices.filter(v => v.engine === 'cfai') as v}
<option value={v.id}>{voiceLabel(v)}</option>
{/each}
{/if}
</select>
</div>
<!-- Chapter range -->
<div class="flex items-end gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<label for="audio-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
<input
id="audio-from"
type="number"
min="1"
bind:value={audioFrom}
placeholder="1"
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<div class="flex flex-col gap-1">
<label for="audio-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
<input
id="audio-to"
type="number"
min="1"
bind:value={audioTo}
placeholder="end"
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<button
onclick={enqueueAudio}
disabled={audioEnqueuing || !audioFrom}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{audioEnqueuing || !audioFrom ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
>
{#if audioEnqueuing}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v6m0 0l-2-2m2 2l2-2M6.343 17.657a8 8 0 010-11.314"/></svg>
{/if}
{m.book_detail_admin_enqueue_audio()}
</button>
<button
onclick={cancelAudio}
class="px-3 py-1.5 rounded text-xs font-medium text-(--color-muted) hover:text-(--color-danger) transition-colors border border-(--color-border)"
>
{m.book_detail_admin_cancel_audio()}
</button>
</div>
{#if audioResult}
<span class="text-xs text-green-400">{m.book_detail_admin_enqueued({ enqueued: audioResult.enqueued, skipped: audioResult.skipped })}</span>
{/if}
{#if audioError}
<span class="text-xs text-(--color-muted)">{audioError}</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>