feat(ui): add Sentry/GlitchTip, Umami analytics, presign auto-trigger TTS, feedback link

- Add @sentry/sveltekit: server-side init in hooks.server.ts, client-side in
  hooks.client.ts; opt-in via PUBLIC_GLITCHTIP_DSN env var; handleError wired up
- Add Umami analytics: script tag in +layout.svelte (opt-in via PUBLIC_UMAMI_WEBSITE_ID);
  track book_view, chapter_read, audio_played events via window.umami?.track
- Presign audio endpoint now auto-triggers Kokoro TTS generation when audio is
  missing (404 → POST /api/audio → return 202); AudioPlayer handles 202 status
- Fix listAudioCache: was querying non-existent audio_cache collection; now projects
  from audio_jobs where status='done'
- Add Feedback nav link (desktop + mobile + footer) pointing to feedback.libnovel.cc
- Admin scrape page: add full catalogue scrape button; fix retryTask for catalogue
  kind; show retry button for all failed/cancelled tasks regardless of kind
This commit is contained in:
Admin
2026-03-23 16:35:58 +05:00
parent 16a12ede4d
commit 0ed1112b20
13 changed files with 2368 additions and 152 deletions

2245
v3/ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"@sentry/sveltekit": "^10.45.0",
"cropperjs": "^1.6.2",
"ioredis": "^5.3.2",
"marked": "^17.0.3",

8
v3/ui/src/app.d.ts vendored
View File

@@ -13,6 +13,14 @@ declare global {
// interface PageState {}
// interface Platform {}
}
// Umami analytics — injected by the script tag in +layout.svelte when
// PUBLIC_UMAMI_WEBSITE_ID is set. Optional so calls are no-ops in dev.
interface Window {
umami?: {
track(event: string, data?: Record<string, unknown>): void;
};
}
}
export {};

13
v3/ui/src/hooks.client.ts Normal file
View File

@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/sveltekit';
import { env } from '$env/dynamic/public';
// Sentry / GlitchTip client-side error tracking.
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
if (env.PUBLIC_GLITCHTIP_DSN) {
Sentry.init({
dsn: env.PUBLIC_GLITCHTIP_DSN,
tracesSampleRate: 0.1
});
}
export const handleError = Sentry.handleErrorWithSentry();

View File

@@ -1,10 +1,24 @@
import type { Handle } from '@sveltejs/kit';
import { handleErrorWithSentry } from '@sentry/sveltekit';
import * as Sentry from '@sentry/sveltekit';
import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger';
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
import { drain as drainPresignCache } from '$lib/server/presignCache';
// ─── Sentry / GlitchTip server-side error tracking ────────────────────────────
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
if (pubEnv.PUBLIC_GLITCHTIP_DSN) {
Sentry.init({
dsn: pubEnv.PUBLIC_GLITCHTIP_DSN,
tracesSampleRate: 0.1
});
}
export const handleError = handleErrorWithSentry();
// ─── Graceful shutdown ────────────────────────────────────────────────────────
//
// When Docker/Kubernetes sends SIGTERM (or the user sends SIGINT), we:

View File

@@ -327,7 +327,9 @@
voice: targetVoice
});
const res = await fetch(`/api/presign/audio?${params}`);
if (res.status === 404) return null;
// 202: TTS was just enqueued by the presign endpoint — audio not ready yet.
// 404: legacy fallback (should no longer occur after endpoint change).
if (res.status === 202 || res.status === 404) return null;
if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
const data = (await res.json()) as { url: string };
return data.url;
@@ -608,6 +610,11 @@
// Not yet loaded — start the full flow.
await startPlayback();
// Track audio play after successful load.
if (audioStore.status === 'ready') {
window.umami?.track('audio_played', { slug, chapter });
}
}
function formatTime(s: number): string {

View File

@@ -662,6 +662,8 @@ export async function setAudioTime(
}
// ─── Audio cache ──────────────────────────────────────────────────────────────
// There is no separate audio_cache collection — completed audio jobs in the
// audio_jobs collection serve as the cache record. We project them here.
export interface AudioCacheEntry {
id: string;
@@ -671,7 +673,13 @@ export interface AudioCacheEntry {
}
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
return listAll<AudioCacheEntry>('audio_cache', '', '-updated');
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', '-finished');
return jobs.map((j) => ({
id: j.id,
cache_key: j.cache_key,
filename: `${j.cache_key}.mp3`,
updated: j.finished
}));
}
// ─── Scraping tasks ───────────────────────────────────────────────────────────

View File

@@ -170,6 +170,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>libnovel</title>
<!-- Umami analytics — no-op when PUBLIC_UMAMI_WEBSITE_ID is unset -->
{#if env.PUBLIC_UMAMI_WEBSITE_ID}
<script
defer
src="https://analytics.libnovel.cc/script.js"
data-website-id={env.PUBLIC_UMAMI_WEBSITE_ID}
></script>
{/if}
</svelte:head>
<!-- Persistent audio element — always in the DOM, never conditionally unmounted.
@@ -232,12 +240,20 @@
>
Library
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Discover
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Discover
</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:block text-sm transition-colors text-zinc-400 hover:text-zinc-100"
>
Feedback
</a>
<div class="ml-auto flex items-center gap-4">
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
@@ -311,13 +327,22 @@
>
Library
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Discover
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Discover
</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
Feedback ↗
</a>
<a
href="/profile"
onclick={() => (menuOpen = false)}
@@ -374,10 +399,22 @@
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-zinc-600">
<!-- Top row: site links -->
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
<a href="/catalogue" class="hover:text-zinc-400 transition-colors">Discover</a>
<a
href="https://novelfire.net"
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
<a href="/catalogue" class="hover:text-zinc-400 transition-colors">Discover</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
>
Feedback
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<a
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1"

View File

@@ -32,6 +32,33 @@
tasks = data.tasks;
});
// ── Full catalogue scrape ───────────────────────────────────────────────────
let catalogueError = $state('');
let cataloguing = $state(false);
async function triggerCatalogueScrape() {
if (running || cataloguing) return;
cataloguing = true;
catalogueError = '';
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
catalogueError = d.error ?? d.message ?? `Error ${res.status}`;
} else {
running = true;
}
} catch {
catalogueError = 'Network error.';
} finally {
cataloguing = false;
}
}
// ── Single book scrape ──────────────────────────────────────────────────────
let scrapeUrl = $state('');
let scrapeError = $state('');
@@ -114,13 +141,14 @@
}
function retryTask(task: ScrapingTask) {
if (task.kind === 'book_range') {
if (task.kind === 'catalogue') {
triggerCatalogueScrape();
} else if (task.kind === 'book_range') {
rangeUrl = task.target_url ?? '';
rangeFrom = task.from_chapter ?? 1;
rangeTo = task.to_chapter > 0 ? task.to_chapter : null;
scrollToRangeForm();
} else {
// book / catalogue — just pre-fill the single-book form
scrapeUrl = task.target_url ?? '';
scrollToBookForm();
}
@@ -220,7 +248,25 @@
</div>
<!-- Scrape controls -->
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Full catalogue -->
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<div>
<h2 class="text-sm font-semibold text-zinc-300">Scrape full catalogue</h2>
<p class="text-xs text-zinc-500 mt-1">Re-crawls all novelfire.net pages and picks up new books.</p>
</div>
<button
onclick={triggerCatalogueScrape}
disabled={running || cataloguing}
class="w-full px-4 py-2 rounded-lg bg-amber-600 text-zinc-900 font-semibold text-sm hover:bg-amber-500 transition-colors disabled:opacity-50"
>
{cataloguing ? 'Queuing…' : running ? 'Already running…' : 'Start catalogue scrape'}
</button>
{#if catalogueError}
<p class="text-sm text-red-400">{catalogueError}</p>
{/if}
</div>
<!-- Single book -->
<div id="book-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
@@ -379,7 +425,7 @@
Continue ▶
</button>
{/if}
{#if (task.status === 'failed' || task.status === 'cancelled') && task.kind !== 'catalogue'}
{#if task.status === 'failed' || task.status === 'cancelled'}
<button
onclick={() => retryTask(task)}
class="px-2 py-1 rounded text-xs font-medium bg-sky-900/60 text-sky-300 hover:bg-sky-800/60 transition-colors"
@@ -447,7 +493,7 @@
Continue ▶
</button>
{/if}
{#if (task.status === 'failed' || task.status === 'cancelled') && task.kind !== 'catalogue'}
{#if task.status === 'failed' || task.status === 'cancelled'}
<button
onclick={() => retryTask(task)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-900/60 text-sky-300 hover:bg-sky-800/60 transition-colors"

View File

@@ -3,12 +3,23 @@ import type { RequestHandler } from './$types';
import { presignAudio } from '$lib/server/minio';
import { log } from '$lib/server/logger';
import * as cache from '$lib/server/presignCache';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/presign/audio?slug=...&n=...&voice=...
* Returns a presigned MinIO URL for the audio file so the browser
* can stream it directly without going through the server.
* Returns 404 when the audio has not been generated yet.
*
* Returns a presigned MinIO URL for the audio file so the client can stream
* it directly without going through the server.
*
* When the audio has not been generated yet, this endpoint automatically
* enqueues a TTS generation job and returns 202 Accepted with the job status,
* so callers can poll GET /api/audio/status/{slug}/{n}?voice=... until done,
* then call this endpoint again to get the URL.
*
* Responses:
* 200 { url: string } — audio ready, stream from MinIO
* 202 { task_id: string, status: string } — TTS enqueued, poll for completion
* 202 { status: "pending"|"generating" } — TTS already in progress
*
* Results are cached in-process for 50 minutes (MinIO URLs are valid 1 hour)
* to avoid a backend + MinIO round-trip on every "Play" click.
@@ -38,10 +49,52 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ url: presignedUrl });
} catch (e) {
const status = (e as { status?: number }).status;
if (status === 404) {
error(404, 'Audio not found');
if (status !== 404) {
log.error('presign', 'presign audio failed', { slug, n, err: String(e) });
error(500, `Could not get presigned URL: ${e}`);
}
log.error('presign', 'presign audio failed', { slug, n, err: String(e) });
error(500, `Could not get presigned URL: ${e}`);
}
// Audio not found — automatically trigger TTS generation so the caller
// doesn't need a separate POST step. Return 202 with the job status.
log.info('presign', 'audio not found, triggering TTS generation', { slug, n, voice });
try {
const triggerRes = await backendFetch(`/api/audio/${slug}/${n}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(voice ? { voice } : {})
});
if (!triggerRes.ok) {
const text = await triggerRes.text().catch(() => '');
log.error('presign', 'audio trigger failed', { slug, n, status: triggerRes.status, body: text });
error(triggerRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
}
const data = (await triggerRes.json()) as
| { url: string; status: 'done' }
| { task_id: string; status: string };
// If the backend says it's already done (race: generated between presign
// check and the POST), try to presign once more and return 200.
if (triggerRes.status === 200 || ('status' in data && data.status === 'done')) {
try {
const presignedUrl = await presignAudio(slug, n, voice || undefined);
await cache.set(cacheKey, presignedUrl);
return json({ url: presignedUrl });
} catch {
// Ignore — fall through to 202 below.
}
}
// Generation is in progress — return 202 so the caller can poll.
return new Response(JSON.stringify(data), {
status: 202,
headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
if ((e as { status?: number }).status) throw e; // re-throw SvelteKit errors
log.error('presign', 'audio trigger error', { slug, n, err: String(e) });
error(500, `Could not trigger audio generation: ${e}`);
}
};

View File

@@ -1,12 +1,15 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignVoiceSample } from '$lib/server/minio';
import { log } from '$lib/server/logger';
import * as cache from '$lib/server/presignCache';
/**
* GET /api/presign/voice-sample?voice=af_bella
* Returns a presigned URL for the voice sample audio file.
* Returns 404 if the sample has not been generated yet.
*
* The backend generates the sample on demand via Kokoro TTS if it does not
* exist yet, so this endpoint always returns 200 { url } (or 5xx on failure).
*
* Results are cached in-process for 50 minutes to avoid a backend + MinIO
* round-trip on every voice-selection preview play.
@@ -25,16 +28,13 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ url: cached });
}
// Slow path: call backend → MinIO presign.
// Slow path: call backend → generate if needed → MinIO presign.
try {
const presignedUrl = await presignVoiceSample(voice);
await cache.set(cacheKey, presignedUrl);
return json({ url: presignedUrl });
} catch (e) {
const status = (e as { status?: number }).status;
if (status === 404) {
error(404, 'Voice sample not found');
}
log.error('presign', 'presign voice sample failed', { voice, err: String(e) });
error(502, `Failed to presign voice sample: ${e}`);
}
};

View File

@@ -1,11 +1,17 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
let { data }: { data: PageData } = $props();
onMount(() => {
if (data.book?.slug) {
window.umami?.track('book_view', { slug: data.book.slug });
}
});
// ── Save / unsave ─────────────────────────────────────────────────────────
let saved = $state(untrack(() => data.saved));
let saving = $state(false);

View File

@@ -19,6 +19,12 @@
const wordCount = $derived(countWords(html));
onMount(async () => {
// Umami analytics: track chapter reads
window.umami?.track('chapter_read', {
slug: data.book.slug,
chapter: data.chapter.number
});
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {
try {