chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows - Flatten v3/ contents to repo root - Add Doppler secrets management (project=libnovel, config=prd) - Add justfile with doppler run wrappers for all docker compose commands - Strip hardcoded env fallbacks from docker-compose.yml - Add minimal README.md - Clean up .gitignore
This commit is contained in:
@@ -1,19 +1,17 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/admin/scrape/status
|
||||
* Admin-only proxy to the Go scraper's /api/scrape/status endpoint.
|
||||
* Admin-only proxy to the Go backend's /api/scrape/status endpoint.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/scrape/status`);
|
||||
const res = await backendFetch('/api/scrape/status');
|
||||
if (!res.ok) return json({ running: false });
|
||||
const data = await res.json();
|
||||
return json({ running: data.running ?? false });
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* POST /api/audio/[slug]/[n]
|
||||
* Proxies the audio generation request to the scraper's /api/audio endpoint.
|
||||
* Keeps the scraper URL server-side — the browser never needs to know it.
|
||||
* Proxies the audio generation request to the backend's /api/audio endpoint.
|
||||
* Keeps the backend URL server-side — the browser never needs to know it.
|
||||
*
|
||||
* Body: { voice?: string }
|
||||
*
|
||||
* Responses:
|
||||
* 200 { url: string, filename: string } — audio already cached; url is a
|
||||
* relative path to GET /api/audio/[slug]/[n]?voice=...
|
||||
* 202 { job_id: string, status: "pending"|"generating" } — generation
|
||||
* 200 { status: "done" } — audio already cached; client should call
|
||||
* GET /api/presign/audio to obtain a direct MinIO presigned URL.
|
||||
* 202 { task_id: string, status: "pending"|"generating" } — generation
|
||||
* enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
@@ -32,7 +30,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
// empty body is fine — scraper will use defaults
|
||||
}
|
||||
|
||||
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio/${slug}/${chapter}`, {
|
||||
const scraperRes = await backendFetch(`/api/audio/${slug}/${chapter}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
@@ -40,66 +38,28 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
|
||||
if (!scraperRes.ok) {
|
||||
const text = await scraperRes.text().catch(() => '');
|
||||
log.error('audio', 'scraper audio generation failed', { slug, chapter, status: scraperRes.status, body: text });
|
||||
log.error('audio', 'backend audio generation failed', { slug, chapter, status: scraperRes.status, body: text });
|
||||
error(scraperRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
|
||||
}
|
||||
|
||||
const data = (await scraperRes.json()) as
|
||||
| { url: string; filename: string }
|
||||
| { job_id: string; status: string };
|
||||
| { url: string; status: 'done' }
|
||||
| { task_id: string; status: string };
|
||||
|
||||
const voice = body.voice ?? '';
|
||||
const qs = new URLSearchParams();
|
||||
if (voice) qs.set('voice', voice);
|
||||
|
||||
// 202 Accepted: generation enqueued — return job_id + status for polling.
|
||||
if (scraperRes.status === 202 || 'job_id' in data) {
|
||||
// 202 Accepted: generation enqueued — return task_id + status for polling.
|
||||
if (scraperRes.status === 202 || 'task_id' in data) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// 200: audio was already cached — rewrite the proxy URL through our own handler.
|
||||
const cached = data as { url: string; filename: string };
|
||||
// 200: audio was already cached.
|
||||
// Return status only — no url — so the client calls GET /api/presign/audio
|
||||
// and streams directly from MinIO instead of through the Node.js server.
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
url: `/api/audio/${slug}/${chapter}?${qs.toString()}`,
|
||||
filename: cached.filename
|
||||
}),
|
||||
JSON.stringify({ status: 'done' }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/audio/[slug]/[n]?voice=...
|
||||
* Proxies the audio stream from the scraper's /api/audio-proxy endpoint.
|
||||
* This is the URL the browser's <audio> element uses as its src.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
if (!slug || !chapter || chapter < 1) {
|
||||
error(400, 'Invalid slug or chapter number');
|
||||
}
|
||||
|
||||
const voice = url.searchParams.get('voice') ?? '';
|
||||
const qs = new URLSearchParams();
|
||||
if (voice) qs.set('voice', voice);
|
||||
|
||||
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`);
|
||||
|
||||
if (!scraperRes.ok) {
|
||||
log.error('audio', 'scraper audio proxy failed', { slug, chapter, status: scraperRes.status });
|
||||
error(scraperRes.status as Parameters<typeof error>[0], 'Audio not found');
|
||||
}
|
||||
|
||||
// Stream the audio body through — preserve Content-Type and Content-Length.
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', scraperRes.headers.get('Content-Type') ?? 'audio/mpeg');
|
||||
headers.set('Cache-Control', 'public, max-age=3600');
|
||||
const cl = scraperRes.headers.get('Content-Length');
|
||||
if (cl) headers.set('Content-Length', cl);
|
||||
|
||||
return new Response(scraperRes.body, { headers });
|
||||
};
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/audio/status/[slug]/[n]?voice=...
|
||||
* Proxies the audio generation status check to the scraper's
|
||||
* Proxies the audio generation status check to the backend's
|
||||
* GET /api/audio/status/{slug}/{n} endpoint.
|
||||
*
|
||||
* Possible responses from scraper (passed through as-is):
|
||||
* {"status":"done","url":"/api/audio-proxy/...","filename":"..."}
|
||||
* {"status":"pending"|"generating","job_id":"..."}
|
||||
* {"status":"idle"}
|
||||
* {"status":"failed","error":"..."}
|
||||
* Possible responses passed through to the client:
|
||||
* {"status":"done"} — audio ready; no url
|
||||
* {"status":"pending"|"generating","task_id":"..."} — in progress
|
||||
* {"status":"idle"} — no job yet
|
||||
* {"status":"failed","error":"..."} — last job failed
|
||||
*
|
||||
* When status is "done" the scraper returns a proxy URL pointing to its own
|
||||
* /api/audio-proxy/... — we rewrite this to our own
|
||||
* /api/audio/[slug]/[n]?voice=... so the browser never calls the scraper.
|
||||
* When status is "done" the scraper's internal proxy URL is stripped — the
|
||||
* client must call GET /api/presign/audio to obtain a direct MinIO presigned
|
||||
* URL. This avoids streaming audio through the Node.js server.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
@@ -31,13 +29,13 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (voice) qs.set('voice', voice);
|
||||
|
||||
const scraperRes = await fetch(
|
||||
`${SCRAPER_URL}/api/audio/status/${slug}/${chapter}?${qs.toString()}`
|
||||
const scraperRes = await backendFetch(
|
||||
`/api/audio/status/${slug}/${chapter}?${qs.toString()}`
|
||||
);
|
||||
|
||||
if (!scraperRes.ok) {
|
||||
const text = await scraperRes.text().catch(() => '');
|
||||
log.error('audio', 'scraper audio status check failed', {
|
||||
log.error('audio', 'backend audio status check failed', {
|
||||
slug,
|
||||
chapter,
|
||||
status: scraperRes.status,
|
||||
@@ -48,17 +46,16 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
|
||||
const data = (await scraperRes.json()) as {
|
||||
status: string;
|
||||
job_id?: string;
|
||||
task_id?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Rewrite the proxy URL if the audio is done so it routes through us.
|
||||
if (data.status === 'done' && data.url) {
|
||||
const rewrittenQs = new URLSearchParams();
|
||||
if (voice) rewrittenQs.set('voice', voice);
|
||||
data.url = `/api/audio/${slug}/${chapter}?${rewrittenQs.toString()}`;
|
||||
// Strip the backend's internal proxy URL from "done" responses.
|
||||
// The client will call GET /api/presign/audio to get a direct MinIO URL,
|
||||
// avoiding streaming audio through the Node.js server.
|
||||
if (data.status === 'done') {
|
||||
delete data.url;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* POST /api/audio/voice-samples
|
||||
* Triggers generation of voice sample audio files for all (or specified) voices.
|
||||
* Proxies to the scraper's POST /api/audio/voice-samples endpoint.
|
||||
* Optional body: { voices: string[] } to generate a subset.
|
||||
* Returns: { generated: string[], skipped: string[], failed: string[] }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let body: { voices?: string[] } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
// Empty body is fine — generates all voices
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/audio/voice-samples`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
return json(data, { status: res.ok ? 200 : res.status });
|
||||
} catch (e) {
|
||||
return json({ error: String(e) }, { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -2,22 +2,15 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
interface PreviewChapter {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/book/[slug]
|
||||
* Returns book metadata, chapter list, progress, and library status.
|
||||
* Falls back to a live scraper preview if the book is not in PocketBase.
|
||||
*
|
||||
* Response shape mirrors BookDetailResponse in the iOS APIClient.
|
||||
* If the book is not yet in PocketBase, asks the backend to enqueue a scrape
|
||||
* task and returns 202 with { scraping: true, task_id }.
|
||||
* The client should poll and retry once the task completes.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
@@ -44,35 +37,31 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
return json({
|
||||
book,
|
||||
chapters,
|
||||
preview_chapters: null,
|
||||
in_lib: true,
|
||||
saved,
|
||||
last_chapter: progress?.chapter ?? null
|
||||
last_chapter: progress?.chapter ?? null,
|
||||
scraping: false,
|
||||
task_id: null
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to live scraper preview
|
||||
// Fall back to backend: enqueue scrape task if not in library.
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`);
|
||||
const res = await backendFetch(`/api/book-preview/${encodeURIComponent(slug)}`);
|
||||
|
||||
if (res.status === 202) {
|
||||
const body: { task_id: string; message: string } = await res.json();
|
||||
log.info('api/book', 'scrape task enqueued', { slug, task_id: body.task_id });
|
||||
return json({ scraping: true, task_id: body.task_id, in_lib: false }, { status: 202 });
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
log.warn('api/book', 'book-preview returned error', { slug, status: res.status });
|
||||
error(404, `Book "${slug}" not found`);
|
||||
}
|
||||
const preview: {
|
||||
in_lib: boolean;
|
||||
meta: {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[];
|
||||
summary: string;
|
||||
total_chapters: number;
|
||||
source_url: string;
|
||||
};
|
||||
chapters: PreviewChapter[];
|
||||
} = await res.json();
|
||||
|
||||
// 200 — book was already in library
|
||||
const preview: BookPreviewResponse = await res.json();
|
||||
|
||||
const previewBook = {
|
||||
id: '',
|
||||
@@ -91,11 +80,12 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
|
||||
return json({
|
||||
book: previewBook,
|
||||
chapters: [],
|
||||
preview_chapters: preview.chapters,
|
||||
in_lib: preview.in_lib,
|
||||
chapters: preview.chapters,
|
||||
in_lib: true,
|
||||
saved: false,
|
||||
last_chapter: null
|
||||
last_chapter: null,
|
||||
scraping: false,
|
||||
task_id: null
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/browse-page?page=2&genre=all&sort=popular&status=all
|
||||
*
|
||||
* Thin proxy to the Go scraper's /api/browse endpoint.
|
||||
* Used by the infinite-scroll browse page to append subsequent pages
|
||||
* without a full SSR navigation.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const page = url.searchParams.get('page') ?? '1';
|
||||
const genre = url.searchParams.get('genre') ?? 'all';
|
||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
|
||||
const params = new URLSearchParams({ page, genre, sort, status });
|
||||
const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('browse-page', 'scraper returned error', { status: res.status });
|
||||
throw error(502, `Browse fetch failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return json(data);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('browse-page', 'network error', { err: String(e) });
|
||||
throw error(502, 'Could not reach browse service');
|
||||
}
|
||||
};
|
||||
47
ui/src/routes/api/catalogue-page/+server.ts
Normal file
47
ui/src/routes/api/catalogue-page/+server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { bookToListing, type CatalogueResponse } from '$lib/server/catalogue';
|
||||
|
||||
/**
|
||||
* GET /api/catalogue-page?page=2&genre=all&sort=popular&status=all&q=
|
||||
*
|
||||
* Thin proxy to the Go backend's /api/catalogue endpoint.
|
||||
* Used by the infinite-scroll catalogue page to append subsequent pages
|
||||
* without a full SSR navigation.
|
||||
*
|
||||
* Returns { novels, page, hasNext } — the shape expected by the client-side
|
||||
* infinite scroll in +page.svelte.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const page = url.searchParams.get('page') ?? '1';
|
||||
const genre = url.searchParams.get('genre') ?? 'all';
|
||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
|
||||
const params = new URLSearchParams({ page, genre, sort, status });
|
||||
if (q.trim().length >= 2) {
|
||||
params.set('q', q.trim());
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await backendFetch(`/api/catalogue?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
log.error('catalogue-page', 'backend returned error', { status: res.status });
|
||||
throw error(502, `Catalogue fetch failed: ${res.status}`);
|
||||
}
|
||||
const data: CatalogueResponse = await res.json();
|
||||
|
||||
return json({
|
||||
novels: (data.books ?? []).map(bookToListing),
|
||||
page: data.page ?? (parseInt(page, 10) || 1),
|
||||
hasNext: data.has_next ?? false
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('catalogue-page', 'network error', { err: String(e) });
|
||||
throw error(502, 'Could not reach catalogue service');
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/chapter-text-preview/[slug]/[n]
|
||||
* Proxies to the scraper's /api/chapter-text-preview endpoint.
|
||||
* Proxies to the backend's /api/chapter-text-preview endpoint.
|
||||
* Used client-side when the normal chapter path returns no content
|
||||
* (chapter indexed but not yet scraped to MinIO).
|
||||
*/
|
||||
@@ -25,8 +23,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
if (chapterUrl) qs.set('chapter_url', chapterUrl);
|
||||
if (title) qs.set('title', title);
|
||||
|
||||
const scraperRes = await fetch(
|
||||
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${chapter}?${qs.toString()}`
|
||||
const scraperRes = await backendFetch(
|
||||
`/api/chapter-text-preview/${encodeURIComponent(slug)}/${chapter}?${qs.toString()}`
|
||||
).catch((e) => {
|
||||
log.error('chapter-preview', 'scraper fetch failed', { slug, chapter, err: String(e) });
|
||||
return null;
|
||||
|
||||
@@ -2,11 +2,8 @@ import { json, error } from '@sveltejs/kit';
|
||||
import { marked } from 'marked';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
|
||||
import { presignChapter } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/chapter/[slug]/[n]
|
||||
@@ -33,9 +30,9 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
|
||||
let chapterData: { slug: string; number: number; title: string; text: string; url: string };
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
|
||||
);
|
||||
const res = await backendFetch(
|
||||
`/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
log.error('api/chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
|
||||
error(404, `Chapter ${n} not found`);
|
||||
@@ -53,7 +50,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
|
||||
let voices: string[] = [];
|
||||
try {
|
||||
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
|
||||
const vRes = await backendFetch('/api/voices');
|
||||
if (vRes.ok) {
|
||||
const d = (await vRes.json()) as { voices: string[] };
|
||||
voices = d.voices ?? [];
|
||||
@@ -80,7 +77,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const [book, chapters, voicesRes] = await Promise.all([
|
||||
getBook(slug),
|
||||
listChapterIdx(slug),
|
||||
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
|
||||
backendFetch('/api/voices').catch(() => null)
|
||||
]);
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
@@ -100,13 +97,17 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
|
||||
let html = '';
|
||||
try {
|
||||
const presignUrl = await presignChapter(slug, n);
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
|
||||
if (!res.ok) {
|
||||
log.error('api/chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
|
||||
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
||||
}
|
||||
const markdown = await res.text();
|
||||
html = marked(markdown) as string;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
error(502, 'Could not fetch chapter content');
|
||||
}
|
||||
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
|
||||
@@ -2,32 +2,99 @@ import { json, error } from '@sveltejs/kit';
|
||||
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.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
// Accept both 'n' (web) and 'chapter' (iOS) as the chapter number param
|
||||
const n = parseInt(url.searchParams.get('n') ?? url.searchParams.get('chapter') ?? '', 10);
|
||||
const voice = url.searchParams.get('voice') ?? undefined;
|
||||
const voice = url.searchParams.get('voice') ?? '';
|
||||
|
||||
if (!slug || !n || n < 1) {
|
||||
error(400, 'Missing slug or n');
|
||||
}
|
||||
|
||||
const cacheKey = cache.audioKey(slug, n, voice);
|
||||
|
||||
// Fast path: return cached URL if still valid.
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return json({ url: cached });
|
||||
}
|
||||
|
||||
// Slow path: call backend → MinIO presign.
|
||||
try {
|
||||
const presignedUrl = await presignAudio(slug, n, voice);
|
||||
const presignedUrl = await presignAudio(slug, n, voice || undefined);
|
||||
await cache.set(cacheKey, presignedUrl);
|
||||
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,11 +1,18 @@
|
||||
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.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const voice = url.searchParams.get('voice');
|
||||
@@ -13,14 +20,21 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
error(400, 'Missing voice parameter');
|
||||
}
|
||||
|
||||
const cacheKey = cache.sampleKey(voice);
|
||||
|
||||
// Fast path: return cached URL if still valid.
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return json({ url: cached });
|
||||
}
|
||||
|
||||
// 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,65 +1,56 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { presignAvatarUploadUrl, presignAvatarUrl } from '$lib/server/minio';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
/**
|
||||
* POST /api/profile/avatar
|
||||
* Body: JSON { mime_type: "image/jpeg" | "image/png" | "image/webp" }
|
||||
* Body: raw image bytes (Content-Type: image/jpeg | image/png | image/webp)
|
||||
*
|
||||
* Returns a short-lived presigned PUT URL pointing at MinIO (public endpoint)
|
||||
* so the client can upload the image bytes directly, bypassing the server.
|
||||
* After the PUT completes, the client must call PATCH /api/profile/avatar
|
||||
* with the returned key to record it in PocketBase.
|
||||
*
|
||||
* Returns: { upload_url: string, key: string }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
let mimeType = 'image/jpeg';
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (body?.mime_type) mimeType = body.mime_type;
|
||||
} catch {
|
||||
// default to jpeg if body is missing/invalid
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(mimeType)) {
|
||||
error(400, `Unsupported image type: ${mimeType}. Allowed: jpeg, png, webp`);
|
||||
}
|
||||
|
||||
const { uploadUrl, key } = await presignAvatarUploadUrl(locals.user.id, mimeType);
|
||||
return json({ upload_url: uploadUrl, key });
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/profile/avatar
|
||||
* Body: JSON { key: string }
|
||||
*
|
||||
* Called after the client has successfully PUT the image to MinIO via the
|
||||
* presigned URL. Records the object key in PocketBase and returns a fresh
|
||||
* Uploads the image to MinIO via the Go backend (server-to-server, no browser
|
||||
* → MinIO direct upload), records the key in PocketBase, and returns a fresh
|
||||
* presigned GET URL for immediate display.
|
||||
*
|
||||
* Returns: { avatar_url: string | null }
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ request, locals }) => {
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
let key: string | undefined;
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (typeof body?.key === 'string') key = body.key;
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
const ct = request.headers.get('Content-Type') ?? '';
|
||||
// Strip parameters (e.g. "image/jpeg; charset=utf-8" → "image/jpeg")
|
||||
const mimeType = ct.split(';')[0].trim();
|
||||
|
||||
if (!ALLOWED_TYPES.includes(mimeType)) {
|
||||
error(400, `Unsupported image type. Allowed: image/jpeg, image/png, image/webp`);
|
||||
}
|
||||
|
||||
if (!key) error(400, 'Missing "key" field');
|
||||
// Read the raw body
|
||||
const blob = await request.arrayBuffer();
|
||||
if (blob.byteLength === 0) error(400, 'Empty image body');
|
||||
if (blob.byteLength > 5 * 1024 * 1024) error(413, 'Image too large (max 5 MiB)');
|
||||
|
||||
// Forward directly to Go backend — server-to-server, so internal MinIO is reachable.
|
||||
const uploadRes = await backendFetch(
|
||||
`/api/avatar-upload/${encodeURIComponent(locals.user.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': mimeType },
|
||||
body: blob
|
||||
}
|
||||
);
|
||||
if (!uploadRes.ok) {
|
||||
const text = await uploadRes.text().catch(() => '');
|
||||
error(uploadRes.status as 400 | 500, `Upload failed: ${text || uploadRes.statusText}`);
|
||||
}
|
||||
const { key } = (await uploadRes.json()) as { key: string };
|
||||
|
||||
// Record object key in PocketBase.
|
||||
await updateUserAvatarUrl(locals.user.id, key);
|
||||
|
||||
// Return a fresh presigned GET URL for immediate display.
|
||||
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||
return json({ avatar_url: avatarUrl });
|
||||
};
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/ranking
|
||||
* Proxies to the Go scraper's /api/ranking endpoint.
|
||||
* Proxies to the Go backend's /api/ranking endpoint.
|
||||
* Returns the top-ranked novels list as JSON.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/ranking`);
|
||||
const res = await backendFetch('/api/ranking');
|
||||
if (!res.ok) {
|
||||
log.error('api/ranking', 'scraper returned error', { status: res.status });
|
||||
log.error('api/ranking', 'backend returned error', { status: res.status });
|
||||
error(502, `Ranking fetch failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* POST /api/scrape
|
||||
*
|
||||
* Proxies scrape requests to the Go scraper backend.
|
||||
* Proxies scrape requests to the Go backend.
|
||||
* Admin-only — returns 403 if the authenticated user is not an admin.
|
||||
*
|
||||
* Request body (JSON):
|
||||
@@ -17,10 +17,8 @@
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Admin guard
|
||||
@@ -39,26 +37,25 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const isBookScrape = typeof body.url === 'string' && body.url.length > 0;
|
||||
const endpoint = isBookScrape ? '/scrape/book' : '/scrape';
|
||||
|
||||
const upstream = `${SCRAPER_URL}${endpoint}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(upstream, {
|
||||
res = await backendFetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: isBookScrape ? JSON.stringify({ url: body.url }) : undefined
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('scrape', 'scraper proxy network error', { endpoint, err: String(e) });
|
||||
throw error(502, 'Could not reach scraper');
|
||||
log.error('scrape', 'backend proxy network error', { endpoint, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
if (!res.ok && res.status >= 500) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('scrape', 'scraper returned error', { endpoint, status: res.status, body: text });
|
||||
log.error('scrape', 'backend returned error', { endpoint, status: res.status, body: text });
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
// Pass through the status code from the Go scraper (202, 409, 400, …)
|
||||
// Pass through the status code from the Go backend (202, 409, 400, …)
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
|
||||
40
ui/src/routes/api/scrape/cancel/[id]/+server.ts
Normal file
40
ui/src/routes/api/scrape/cancel/[id]/+server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* POST /api/scrape/cancel/[id]
|
||||
*
|
||||
* Admin-only proxy that cancels a pending scrape (or audio) task by ID.
|
||||
* Forwards the request to the Go backend POST /api/cancel-task/{id}.
|
||||
*
|
||||
* Responses:
|
||||
* 200 OK — task cancelled
|
||||
* 403 Forbidden — not an admin
|
||||
* 409 Conflict — task cannot be cancelled (already running/done/not found)
|
||||
*/
|
||||
|
||||
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 ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
if (!id) {
|
||||
throw error(400, 'Missing task id');
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/cancel-task/${encodeURIComponent(id)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('scrape/cancel', 'network error cancelling task', { id, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* POST /api/scrape/range
|
||||
*
|
||||
* Proxies range-scrape requests to the Go scraper backend at POST /scrape/book/range.
|
||||
* Proxies range-scrape requests to the Go backend at POST /scrape/book/range.
|
||||
* Admin-only.
|
||||
*
|
||||
* Request body (JSON):
|
||||
@@ -17,10 +17,8 @@
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Admin guard
|
||||
@@ -39,22 +37,21 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
throw error(400, 'url and from are required');
|
||||
}
|
||||
|
||||
const upstream = `${SCRAPER_URL}/scrape/book/range`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(upstream, {
|
||||
res = await backendFetch('/scrape/book/range', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: body.url, from: body.from, to: body.to })
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('scrape/range', 'scraper proxy network error', { err: String(e) });
|
||||
throw error(502, 'Could not reach scraper');
|
||||
log.error('scrape/range', 'backend proxy network error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
if (!res.ok && res.status >= 500) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('scrape/range', 'scraper returned error', { status: res.status, body: text });
|
||||
log.error('scrape/range', 'backend returned error', { status: res.status, body: text });
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
19
ui/src/routes/api/scrape/task/[id]/+server.ts
Normal file
19
ui/src/routes/api/scrape/task/[id]/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getScrapingTask } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/scrape/task/[id]
|
||||
*
|
||||
* Returns { id, status, error_message } for a single scraping task.
|
||||
* Used by the book detail page to poll for task completion.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const { id } = params;
|
||||
if (!id) throw error(400, 'Missing task id');
|
||||
|
||||
const task = await getScrapingTask(id).catch(() => null);
|
||||
if (!task) throw error(404, 'Task not found');
|
||||
|
||||
return json({ id: task.id, status: task.status, error_message: task.error_message ?? '' });
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/search?q=<query>
|
||||
* Proxies to the Go scraper's /api/search endpoint.
|
||||
* Proxies to the Go backend's /api/search endpoint.
|
||||
* Returns: { results, local_count, remote_count }
|
||||
*
|
||||
* Response shape mirrors SearchResponse in the iOS APIClient.
|
||||
@@ -19,11 +17,11 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
return json({ results: [], local_count: 0, remote_count: 0 });
|
||||
}
|
||||
|
||||
const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(q.trim())}`;
|
||||
const apiURL = `/api/search?q=${encodeURIComponent(q.trim())}`;
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
const res = await backendFetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('api/search', 'scraper returned error', { status: res.status, q });
|
||||
log.error('api/search', 'backend returned error', { status: res.status, q });
|
||||
error(502, `Search failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -31,6 +29,6 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('api/search', 'network error', { q, err: String(e) });
|
||||
error(502, 'Could not reach search service');
|
||||
error(502, 'Could not reach backend');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/voices
|
||||
* Proxies the voice list from the scraper → Kokoro.
|
||||
* Proxies the voice list from the backend → Kokoro.
|
||||
* Returns { voices: string[] }
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/voices`);
|
||||
const res = await backendFetch('/api/voices');
|
||||
if (!res.ok) {
|
||||
return json({ voices: [] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user