Compare commits

...

2 Commits

Author SHA1 Message Date
root
6776d9106f fix: catalogue job always shows 0 counters after cancel/finish
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 6m5s
Release / Gitea Release (push) Successful in 32s
Two bugs fixed in runScrapeTask / runCatalogueTask:

1. FinishScrapeTask was called with the task's own context, which is
   already cancelled when the task is stopped. The PATCH to PocketBase
   failed silently, leaving all counters at their initial zero values.
   Fix: use a fresh context.WithTimeout(Background, 15s) for the write.

2. BooksFound was double-counted: RunBook already sets BooksFound=1 on
   success, but the accumulation loop added an extra +1 unconditionally,
   reporting 2 books per successful scrape.
   Fix: result.BooksFound += bookResult.BooksFound  (drop the + 1).
2026-04-11 12:33:30 +05:00
root
ada7de466a perf: remove voice picker from profile, parallelize server load
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 1m52s
Release / Docker (push) Successful in 5m58s
Release / Gitea Release (push) Successful in 33s
Remove the TTS voice section from the profile page — it fetched
/api/voices on every mount, blocking paint for the full round-trip.
Voice selection lives on the chapter page where voices are already loaded.

Rewrite the server load to run avatar, sessions+stats, and reading history
all concurrently via Promise.allSettled instead of sequentially, cutting
SSR latency by ~2-3x on the profile route.
2026-04-11 10:41:35 +05:00
3 changed files with 47 additions and 157 deletions

View File

@@ -505,7 +505,11 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
log.Warn("runner: unknown task kind")
}
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
// Use a fresh context for the final write so a cancelled task context doesn't
// prevent the result counters from being persisted to PocketBase.
finishCtx, finishCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer finishCancel()
if err := r.deps.Consumer.FinishScrapeTask(finishCtx, task.ID, result); err != nil {
log.Error("runner: FinishScrapeTask failed", "err", err)
}
@@ -551,7 +555,7 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
TargetURL: entry.URL,
}
bookResult := o.RunBook(ctx, bookTask)
result.BooksFound += bookResult.BooksFound + 1
result.BooksFound += bookResult.BooksFound
result.ChaptersScraped += bookResult.ChaptersScraped
result.ChaptersSkipped += bookResult.ChaptersSkipped
result.Errors += bookResult.Errors

View File

@@ -15,49 +15,58 @@ export const load: PageServerLoad = async ({ locals }) => {
redirect(302, '/login');
}
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
let email: string | null = null;
let polarCustomerId: string | null = null;
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
// Fetch avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
email = record?.email ?? null;
polarCustomerId = record?.polar_customer_id ?? null;
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}
try {
[sessions, stats] = await Promise.all([
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id)
]);
} catch (e) {
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
}
// Reading history — last 50 progress entries with book metadata
let history: { slug: string; chapter: number; updated: string; title: string; cover: string | null }[] = [];
try {
const progress = await allProgress(locals.sessionId, locals.user.id);
// Helper: fetch reading history (progress → books, sequential by necessity)
async function fetchHistory() {
const progress = await allProgress(locals.sessionId, locals.user!.id);
const recent = progress.slice(0, 50);
const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug)));
const bookMap = new Map(books.map((b) => [b.slug, b]));
history = recent.map((p) => ({
return recent.map((p) => ({
slug: p.slug,
chapter: p.chapter,
updated: p.updated,
title: bookMap.get(p.slug)?.title ?? p.slug,
cover: bookMap.get(p.slug)?.cover ?? null
}));
} catch (e) {
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(e) });
}
// Helper: fetch avatar/email/polarCustomerId (getUserByUsername → resolveAvatarUrl)
async function fetchUserRecord() {
const record = await getUserByUsername(locals.user!.username);
const avatarUrl = await resolveAvatarUrl(locals.user!.id, record?.avatar_url);
return {
avatarUrl,
email: record?.email ?? null,
polarCustomerId: record?.polar_customer_id ?? null
};
}
// Run all three independent groups concurrently
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
fetchUserRecord(),
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id),
fetchHistory()
]);
if (userRecord.status === 'rejected')
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(userRecord.reason) });
if (sessionsResult.status === 'rejected')
log.warn('profile', 'sessions fetch failed (non-fatal)', { err: String(sessionsResult.reason) });
if (statsResult.status === 'rejected')
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
if (historyResult.status === 'rejected')
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
const { avatarUrl = null, email = null, polarCustomerId = null } =
userRecord.status === 'fulfilled' ? userRecord.value : {};
const sessions =
sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
const stats =
statsResult.status === 'fulfilled' ? statsResult.value : null;
const history =
historyResult.status === 'fulfilled' ? historyResult.value : [];
return {
user: locals.user,
avatarUrl,

View File

@@ -6,7 +6,6 @@
import type { AudioMode } from '$lib/audio.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import type { Voice } from '$lib/types';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
@@ -84,70 +83,6 @@
function handleCropCancel() { cropFile = null; }
// ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false);
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
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()) + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
}
if (v.engine === 'pocket-tts') {
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${v.gender.toUpperCase()})` : '');
}
// Kokoro: "af_bella" → "Bella (US F)"
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();
const gender = v.gender ? v.gender.toUpperCase() : '?';
return `${name} (${lang} ${gender})`;
}
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
.catch(() => { voicesLoaded = true; });
});
// Voice sample playback
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio = $state<HTMLAudioElement | null>(null);
function stopSample() {
if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; }
samplePlayingVoice = null;
}
async function toggleSample(voiceId: string) {
if (samplePlayingVoice === voiceId) { stopSample(); return; }
stopSample();
samplePlayingVoice = voiceId;
try {
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
if (res.status === 404) { samplePlayingVoice = null; return; }
if (!res.ok) throw new Error();
const { url } = await res.json() as { url: string };
const audio = new Audio(url);
sampleAudio = audio;
audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); };
audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); };
await audio.play();
} catch { samplePlayingVoice = null; }
}
// ── Settings state ────────────────────────────────────────────────────────────
// All changes are written directly into audioStore / theme context.
// The layout's debounced $effect owns the single PUT /api/settings call.
@@ -549,64 +484,6 @@
</div>
</div>
<!-- TTS voice -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_tts_voice()}</p>
{#if !voicesLoaded}
<div class="space-y-2">
{#each [1,2,3] as _}
<div class="h-10 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
{/each}
</div>
{:else if voices.length === 0}
<p class="text-sm text-(--color-muted) italic">No voices available.</p>
{:else}
{#each [
{ label: 'Kokoro (GPU)', voices: kokoroVoices },
{ label: 'Pocket TTS (CPU)', voices: pocketVoices },
{ label: 'Cloudflare AI', voices: cfaiVoices },
].filter(g => g.voices.length > 0) as group}
<div>
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest mb-2">{group.label}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{#each group.voices as v (v.id)}
{@const isSelected = audioStore.voice === v.id}
{@const isPlaying = samplePlayingVoice === v.id}
<div
role="option"
aria-selected={isSelected}
tabindex="0"
onclick={() => { audioStore.voice = v.id; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }}
class={cn(
'flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg border text-sm transition-colors cursor-pointer select-none',
isSelected
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40'
)}
>
<span class="truncate font-medium">{voiceLabel(v)}</span>
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSample(v.id); }}
class={cn(
'shrink-0 px-2 py-0.5 rounded text-xs font-medium transition-colors',
isPlaying
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title={isPlaying ? 'Stop sample' : 'Play sample'}
>
{isPlaying ? 'Stop' : 'Play'}
</button>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</div>
<!-- Playback speed -->
<div class="px-6 py-5 space-y-3">
<div class="flex items-center justify-between">