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:
2245
v3/ui/package-lock.json
generated
2245
v3/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
8
v3/ui/src/app.d.ts
vendored
@@ -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
13
v3/ui/src/hooks.client.ts
Normal 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();
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user