diff --git a/scripts/request-server.js b/scripts/request-server.js new file mode 100644 index 0000000..7fe3228 --- /dev/null +++ b/scripts/request-server.js @@ -0,0 +1,188 @@ +// Novel requests over HTTP, for the "request a novel" page (served through the +// illustrator at /library). Searches and downloads run as short-lived child processes +// of find-novel.js / fetch-novel.js, so their memory (plugins carry their own +// libraries) is returned as soon as they finish; the server itself stays small. +// GET /health +// GET /find?q= -> [{source, id, name, path, url, exact}] (cached 10 min) +// POST /fetch {id, path, name} -> job; one download at a time +// GET /jobs -> recent jobs, newest first +// Nightly (UPDATE_HOUR, local time) update-novels.js appends new free chapters. +// Env: LIBRARY (novels dir, writable), PORT, NOVEL_PLUGIN_CACHE, UPDATE_HOUR. +import { spawn } from 'child_process'; +import fs from 'fs'; +import http from 'http'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const LIBRARY = process.env.LIBRARY || '/library'; +const PORT = Number(process.env.PORT || 8090); +const UPDATE_HOUR = Number(process.env.UPDATE_HOUR || 4); +const FIND_TTL_MS = 10 * 60 * 1000; +const MAX_JOBS = 50; + +const found = new Map(); // query -> {at, results} +const jobs = []; // newest first +let running = null; + +function run(script, args) { + return new Promise(resolve => { + const child = spawn(process.execPath, [path.join(HERE, script), ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + child.stdout.on('data', d => (out += d)); + child.stderr.on('data', d => { + err += d; + if (err.length > 20000) err = err.slice(-10000); + }); + child.on('close', code => resolve({ code, out, err })); + }); +} + +const safeName = s => s.replace(/[/\\:*?"<>|]/g, '-').trim(); + +// One folder per series, named after the book: Kavita groups by folder. +function place(file) { + const name = safeName(path.basename(file, '.epub')); + const dir = path.join(LIBRARY, name); + const target = path.join(dir, `${name}.epub`); + if (fs.existsSync(target)) throw new Error(`already in the library: ${name}`); + fs.mkdirSync(dir, { recursive: true, mode: 0o2775 }); + fs.copyFileSync(file, target); + return name; +} + +async function work() { + if (running) return; + running = jobs.findLast(j => j.status === 'queued'); + if (!running) return; + const job = running; + job.status = 'running'; + job.started = Date.now(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'novel-')); + try { + const { code, out, err } = await run('fetch-novel.js', [ + job.id, + job.path, + tmp, + ]); + const file = out.trim().split('\n').pop(); + if (code !== 0 || !file || !fs.existsSync(file)) { + throw new Error(err.trim().split('\n').pop() || `exit ${code}`); + } + job.progress = err.trim().split('\n')[0] || ''; + job.title = place(file); + job.status = 'done'; + } catch (e) { + job.status = 'failed'; + job.error = String(e.message || e).slice(0, 300); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + job.finished = Date.now(); + running = null; + setImmediate(work); + } +} + +async function nightly() { + const next = new Date(); + next.setHours(UPDATE_HOUR, 17, 0, 0); + if (next <= new Date()) next.setDate(next.getDate() + 1); + setTimeout(async () => { + const { code, out, err } = await run('update-novels.js', [LIBRARY]); + process.stdout.write( + `nightly update (exit ${code}):\n${out}${err.slice(-2000)}\n`, + ); + nightly(); + }, next - new Date()); +} + +function send(res, code, body) { + const data = JSON.stringify(body); + res.writeHead(code, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + 'Cache-Control': 'no-store', + }); + res.end(data); +} + +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', d => { + body += d; + if (body.length > 10000) reject(new Error('body too large')); + }); + req.on('end', () => { + try { + resolve(JSON.parse(body || '{}')); + } catch { + reject(new Error('invalid JSON')); + } + }); + }); +} + +http + .createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + try { + if (req.method === 'GET' && url.pathname === '/health') { + return send(res, 200, { ok: true }); + } + if (req.method === 'GET' && url.pathname === '/find') { + const q = (url.searchParams.get('q') || '').trim().slice(0, 200); + if (q.length < 2) return send(res, 400, { error: 'q is required' }); + const hit = found.get(q.toLowerCase()); + if (hit && Date.now() - hit.at < FIND_TTL_MS) + return send(res, 200, hit.results); + const { code, out, err } = await run('find-novel.js', ['--json', q]); + if (code !== 0) + return send(res, 502, { error: err.trim().split('\n').pop() }); + const results = JSON.parse(out.trim().split('\n').pop() || '[]'); + found.set(q.toLowerCase(), { at: Date.now(), results }); + return send(res, 200, results); + } + if (req.method === 'GET' && url.pathname === '/jobs') { + return send(res, 200, jobs); + } + if (req.method === 'POST' && url.pathname === '/fetch') { + const { id, path: novelPath, name } = await readJson(req); + if ( + typeof id !== 'string' || + typeof novelPath !== 'string' || + !id || + !novelPath + ) + return send(res, 400, { error: 'id and path are required' }); + const same = jobs.find( + j => + j.id === id && + j.path === novelPath && + ['queued', 'running'].includes(j.status), + ); + if (same) return send(res, 202, same); + const job = { + id: id.slice(0, 100), + path: novelPath.slice(0, 500), + name: String(name || novelPath).slice(0, 200), + status: 'queued', + queued: Date.now(), + }; + jobs.unshift(job); + jobs.length = Math.min(jobs.length, MAX_JOBS); + setImmediate(work); + return send(res, 202, job); + } + send(res, 404, { error: 'not found' }); + } catch (e) { + send(res, 400, { error: String(e.message || e) }); + } + }) + .listen(PORT, () => process.stdout.write(`novel requests on :${PORT}\n`)); + +nightly();