|
| 1 | +// sockets/books.exec.ts — ADFA-4838 |
| 2 | +// |
| 3 | +// Books runner for the durable job engine: for each requested Gutenberg book, download |
| 4 | +// the EPUB and upload it to the local Calibre-Web. Ported from the Phase 1 books.socket |
| 5 | +// download_books_batch handler (auth/CSRF + fetch + upload), made durable and reporting |
| 6 | +// structured per-book progress. A job item is { id, title, url }. |
| 7 | +import { jobs, RunnerContext } from './jobs'; |
| 8 | +import fs from 'fs'; |
| 9 | +import path from 'path'; |
| 10 | + |
| 11 | +const CALIBRE_WEB_LOCAL_URL = 'http://127.0.0.1:8083'; |
| 12 | +const TMP_DIR = '/tmp/books_downloader/'; |
| 13 | +const SYSTEM_USER_AGENT = 'K2Go Dashboard/1.0 (https://github.com/appdevforall/KnowledgeToGo)'; |
| 14 | +// Defaults match the Phase 1 handler; a credential override can be added later if needed. |
| 15 | +const CALIBRE_WEB_USER = 'Admin'; |
| 16 | +const CALIBRE_WEB_PASS = 'changeme'; |
| 17 | + |
| 18 | +interface BookItem { id?: string; title?: string; url?: string; } |
| 19 | + |
| 20 | +/** Authenticate against Calibre-Web and return a usable cookie + fresh CSRF token. */ |
| 21 | +async function getCalibreSession(): Promise<{ cookie: string; csrfToken: string }> { |
| 22 | + const loginPageRes = await fetch(`${CALIBRE_WEB_LOCAL_URL}/login`); |
| 23 | + const initialCookies = loginPageRes.headers.getSetCookie().map((c) => c.split(';')[0]).join('; '); |
| 24 | + const loginHtml = await loginPageRes.text(); |
| 25 | + |
| 26 | + const csrfMatch = loginHtml.match(/name="csrf_token" value="(.*?)"/); |
| 27 | + if (!csrfMatch) throw new Error('Could not find CSRF token on login page'); |
| 28 | + const csrfToken = csrfMatch[1]; |
| 29 | + |
| 30 | + const loginData = new URLSearchParams(); |
| 31 | + loginData.append('csrf_token', csrfToken); |
| 32 | + loginData.append('username', CALIBRE_WEB_USER); |
| 33 | + loginData.append('password', CALIBRE_WEB_PASS); |
| 34 | + |
| 35 | + const authRes = await fetch(`${CALIBRE_WEB_LOCAL_URL}/login`, { |
| 36 | + method: 'POST', |
| 37 | + headers: { |
| 38 | + Cookie: initialCookies, |
| 39 | + 'Content-Type': 'application/x-www-form-urlencoded', |
| 40 | + Referer: `${CALIBRE_WEB_LOCAL_URL}/login`, |
| 41 | + }, |
| 42 | + body: loginData, |
| 43 | + redirect: 'manual', |
| 44 | + }); |
| 45 | + |
| 46 | + if (authRes.status !== 302 && authRes.status !== 303) { |
| 47 | + throw new Error('Invalid Calibre-Web credentials'); |
| 48 | + } |
| 49 | + |
| 50 | + const authCookieString = authRes.headers.getSetCookie().map((c) => c.split(';')[0]).join('; '); |
| 51 | + |
| 52 | + const homePageRes = await fetch(`${CALIBRE_WEB_LOCAL_URL}/`, { headers: { Cookie: authCookieString } }); |
| 53 | + const homeHtml = await homePageRes.text(); |
| 54 | + const finalCsrfMatch = |
| 55 | + homeHtml.match(/name="csrf_token"\s+value="([^"]+)"/i) || |
| 56 | + homeHtml.match(/value="([^"]+)"\s+name="csrf_token"/i); |
| 57 | + const finalCsrfToken = finalCsrfMatch ? finalCsrfMatch[1] : csrfToken; |
| 58 | + |
| 59 | + return { cookie: authCookieString, csrfToken: finalCsrfToken }; |
| 60 | +} |
| 61 | + |
| 62 | +const booksRunner: (ctx: RunnerContext) => Promise<void> = async (ctx) => { |
| 63 | + if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true }); |
| 64 | + |
| 65 | + const books = ctx.items |
| 66 | + .map((x) => x as BookItem) |
| 67 | + .filter((b) => b && typeof b.url === 'string' && typeof b.id === 'string'); |
| 68 | + if (books.length === 0) throw new Error('no books requested'); |
| 69 | + |
| 70 | + ctx.update({ phase: 'processing', percent: 0 }); |
| 71 | + |
| 72 | + let session: { cookie: string; csrfToken: string }; |
| 73 | + try { |
| 74 | + session = await getCalibreSession(); |
| 75 | + } catch (e) { |
| 76 | + throw new Error('Calibre-Web authentication failed'); |
| 77 | + } |
| 78 | + |
| 79 | + let done = 0; |
| 80 | + for (const book of books) { |
| 81 | + ctx.throwIfCanceled(); |
| 82 | + const id = String(book.id); |
| 83 | + const title = String(book.title ?? id); |
| 84 | + const url = String(book.url); |
| 85 | + ctx.update({ phase: 'processing', detail: title, percent: Math.round((done / books.length) * 100) }); |
| 86 | + |
| 87 | + const tmp = path.join(TMP_DIR, `pg_${id}.epub`); |
| 88 | + try { |
| 89 | + const response = await fetch(url, { |
| 90 | + headers: { 'User-Agent': SYSTEM_USER_AGENT, Accept: 'application/epub+zip' }, |
| 91 | + }); |
| 92 | + if (!response.ok) throw new Error(`HTTP ${response.status} from Gutenberg`); |
| 93 | + |
| 94 | + const fileBuffer = await response.arrayBuffer(); |
| 95 | + fs.writeFileSync(tmp, Buffer.from(fileBuffer)); |
| 96 | + |
| 97 | + const form = new FormData(); |
| 98 | + form.append('csrf_token', session.csrfToken); |
| 99 | + form.append('btn-upload', new Blob([fileBuffer], { type: 'application/epub+zip' }), `${title}.epub`); |
| 100 | + |
| 101 | + const uploadRes = await fetch(`${CALIBRE_WEB_LOCAL_URL}/upload`, { |
| 102 | + method: 'POST', |
| 103 | + headers: { Cookie: session.cookie, Referer: `${CALIBRE_WEB_LOCAL_URL}/` }, |
| 104 | + body: form, |
| 105 | + }); |
| 106 | + if (!uploadRes.ok) throw new Error(`Calibre-Web rejected upload: ${uploadRes.status}`); |
| 107 | + } finally { |
| 108 | + if (fs.existsSync(tmp)) fs.unlinkSync(tmp); |
| 109 | + } |
| 110 | + |
| 111 | + done++; |
| 112 | + ctx.update({ phase: 'processing', percent: Math.round((done / books.length) * 100) }); |
| 113 | + } |
| 114 | + |
| 115 | + ctx.update({ phase: 'done', percent: 100 }); |
| 116 | +}; |
| 117 | + |
| 118 | +jobs.registerRunner('books', booksRunner); |
| 119 | + |
| 120 | +export { booksRunner }; |
0 commit comments