From fbb91a9c84f722767fb36f03d906daf2e1083d39 Mon Sep 17 00:00:00 2001 From: masi Date: Fri, 25 Sep 2026 12:58:19 -0300 Subject: [PATCH] feat(multi): add Kavita plugin; keep request logs out of find output Kavita plugin reads EPUB series from a self-hosted Kavita server via its plugin API key, one LNReader chapter per EPUB page named from the TOC. Co-Authored-By: Claude Opus 5.5 --- plugins/multi/kavita.ts | 226 ++++++++++++++++++++++++ public/static/src/multi/kavita/icon.png | Bin 0 -> 2137 bytes scripts/find-novel.js | 6 +- 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 plugins/multi/kavita.ts create mode 100644 public/static/src/multi/kavita/icon.png diff --git a/plugins/multi/kavita.ts b/plugins/multi/kavita.ts new file mode 100644 index 0000000..e62800e --- /dev/null +++ b/plugins/multi/kavita.ts @@ -0,0 +1,226 @@ +import { fetchApi, type FetchInit } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { storage } from '@libs/storage'; + +// Reads EPUB series from a self-hosted Kavita server. Each LNReader chapter is +// one EPUB page (spine item), named from the book's table of contents. + +type Library = { id: number; type: number }; +type Series = { id: number; name: string; libraryId: number }; +type SearchResult = { + series?: { seriesId: number; name: string; libraryId: number }[]; +}; +type Volume = { + name: string; + chapters: { id: number; title: string; pages: number }[]; +}; +type TocItem = { title: string; page: number; children?: TocItem[] }; +type Metadata = { + summary?: string; + genres?: { title: string }[]; + writers?: { name: string }[]; + publicationStatus?: number; +}; + +const BOOK_LIBRARY_TYPES = new Set([2, 4]); // Book, LightNovel +const STATUS: Record = { + 0: NovelStatus.Ongoing, + 1: NovelStatus.OnHiatus, + 2: NovelStatus.Completed, + 3: NovelStatus.Cancelled, + 4: NovelStatus.Completed, +}; +const PAGE_SIZE = 20; + +class KavitaPlugin implements Plugin.PluginBase { + id = 'kavita'; + name = 'Kavita'; + icon = 'src/multi/kavita/icon.png'; + version = '1.0.0'; + site = 'https://www.kavitareader.com'; + + pluginSettings = { + url: { value: '', label: 'Server URL (e.g. https://novel.example.com)' }, + apiKey: { value: '', label: 'API key (Kavita: Settings → Account)' }, + }; + + token?: string; + + get base(): string { + const url = String(storage.get('url') || '') + .trim() + .replace(/\/+$/, ''); + if (!url) + throw new Error('Set the Kavita server URL in the plugin settings'); + return url; + } + + get apiKey(): string { + const key = String(storage.get('apiKey') || '').trim(); + if (!key) throw new Error('Set your Kavita API key in the plugin settings'); + return key; + } + + async authenticate(): Promise { + const res = await fetchApi( + `${this.base}/api/Plugin/authenticate?apiKey=${encodeURIComponent(this.apiKey)}&pluginName=LNReader`, + { method: 'POST' }, + ); + if (!res.ok) + throw new Error(`Kavita login failed (${res.status}); check the API key`); + this.token = (await res.json()).token; + return this.token!; + } + + async request( + path: string, + init: FetchInit = {}, + retry = true, + ): Promise { + const token = this.token || (await this.authenticate()); + const res = await fetchApi(`${this.base}/api/${path}`, { + ...init, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...(init.headers as Record), + }, + }); + if (res.status === 401 && retry) { + this.token = undefined; + return this.request(path, init, false); + } + if (!res.ok) throw new Error(`Kavita ${res.status} for ${path}`); + return res; + } + + json = async (path: string, init?: FetchInit): Promise => + (await this.request(path, init)).json(); + + async bookLibraryIds(): Promise { + const libs = await this.json('Library/libraries'); + return libs.filter(l => BOOK_LIBRARY_TYPES.has(l.type)).map(l => l.id); + } + + cover = (seriesId: number) => + `${this.base}/api/image/series-cover?seriesId=${seriesId}&apiKey=${encodeURIComponent(this.apiKey)}`; + + toItem = (s: { id: number; name: string }): Plugin.NovelItem => ({ + name: s.name, + path: String(s.id), + cover: this.cover(s.id), + }); + + async popularNovels( + pageNo: number, + { showLatestNovels }: Plugin.PopularNovelsOptions, + ): Promise { + const libraries = await this.bookLibraryIds(); + if (!libraries.length) return []; + const series = await this.json( + `Series/all-v2?PageNumber=${pageNo}&PageSize=${PAGE_SIZE}`, + { + method: 'POST', + body: JSON.stringify({ + statements: [ + { comparison: 5, field: 19, value: libraries.join(',') }, + ], + combination: 1, + // LastChapterAdded (newest first) for "latest", otherwise by name. + sortOptions: showLatestNovels + ? { sortField: 4, isAscending: false } + : { sortField: 1, isAscending: true }, + limitTo: 0, + }), + }, + ); + return series.map(this.toItem); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + const [libraries, result] = await Promise.all([ + this.bookLibraryIds(), + this.json( + `Search/search?queryString=${encodeURIComponent(searchTerm)}&includeChapterAndFiles=false`, + ), + ]); + return (result.series || []) + .filter(s => libraries.includes(s.libraryId)) + .map(s => this.toItem({ id: s.seriesId, name: s.name })); + } + + async parseNovel(novelPath: string): Promise { + const [series, meta, volumes] = await Promise.all([ + this.json(`Series/${novelPath}`), + this.json(`Series/metadata?seriesId=${novelPath}`), + this.json(`Series/volumes?seriesId=${novelPath}`), + ]); + + const books = volumes.flatMap(v => v.chapters); + const chapters: Plugin.ChapterItem[] = []; + for (const book of books) { + const toc = await this.json(`Book/${book.id}/chapters`); + const titles = new Map(); + const walk = (items: TocItem[]) => + items.forEach(item => { + if (!titles.has(item.page)) titles.set(item.page, item.title); + if (item.children?.length) walk(item.children); + }); + walk(toc); + + const prefix = books.length > 1 ? `${book.title} - ` : ''; + let part = 0; + for (let page = 0; page < book.pages; page++) { + const title = titles.get(page); + chapters.push({ + name: prefix + (title || `Part ${++part}`), + path: `${book.id}/${page}`, + chapterNumber: chapters.length + 1, + }); + } + } + + return { + path: novelPath, + name: series.name, + cover: this.cover(series.id), + author: meta.writers?.map(w => w.name).join(', '), + genres: meta.genres?.map(g => g.title).join(', '), + summary: meta.summary?.replace(/<[^>]+>/g, '').trim(), + status: + meta.publicationStatus !== undefined + ? STATUS[meta.publicationStatus] || NovelStatus.Unknown + : NovelStatus.Unknown, + chapters, + }; + } + + async parseChapter(chapterPath: string): Promise { + const [bookId, page] = chapterPath.split('/'); + const html = await ( + await this.request(`Book/${bookId}/book-page?page=${page}`) + ).text(); + const protocol = this.base.startsWith('http://') ? 'http:' : 'https:'; + return ( + html + // Kavita's reader stylesheet forces its own colours; let LNReader theme the text. + .replace(//gi, '') + // Resource URLs are protocol-relative (//host/api/book/...). + .replace(/((?:src|href|xlink:href)=["'])\/\//g, `$1${protocol}//`) + ); + } + + resolveUrl = (path: string, isNovel?: boolean) => { + const base = String(storage.get('url') || this.site).replace(/\/+$/, ''); + return isNovel + ? `${base}/library/0/series/${path}` + : `${base}/library/0/series/0/book/${path.split('/')[0]}`; + }; +} + +export default new KavitaPlugin(); diff --git a/public/static/src/multi/kavita/icon.png b/public/static/src/multi/kavita/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d0293fc6345bfe98874106516f0e3b8585e1b312 GIT binary patch literal 2137 zcmZ{kXHZlL62~8kf_NyWfJ;zt6@(LHNbZS=NlYLIbwofEK~$751Bie`B@RKdqyZ!! z83aj!BCswX7(lF*Fd}gVnD}Piq?`S6Rrg^(ba(Z?>sQ^?)zwK3b{Itkbp-$b6meLT zlQeq%W%4_v{)<(@Uj=P1tdlJOJkkY#=y(8tq*C-M00hGTVBHe{Eb;;1P(V?)<2eA> z(c@s_g2oSLIi-?3)hy8X(79J7tS#@I6`s49RJD`==u@7_7Uu(3q@ivg9*~;Y}zK9MrUm^xFV4V z>m{Ly(XQ^5ehrohCt-N$~O%*h3Qv zgd(9(#1~20)&<#%P!1K!r9xj=qO}bseTyw!1P6pboJ|tM(V6Lt+ z=vy3tK*;9uSsdOvjk!)^Z810u7Pzs=-ePd++Z-NGFt@N=T-I^p4L_QV4ttF2 z&cx&juf&!YjyI8UPcR$!SB_8AB6u7h`h^ z^M6~wPM?IqEMTzov$ym954i7t>yA&@-veTw`IAyW?|&UgckX)=NjL5X{5?}k6te+9 zHWY_~yZFx03;5v&(Z588R=?9mzmjDP!5h&s3bL^ zDC^0**VhKgi03>Qy64w8%7qi#^-M(mC6MgIr4cFILXqS}FGLpPi+nlw5rm zqn;#YXe*RQ4;7_?zBy0*)4Jp{mfBYi(gN{4CZuS5MfzkgHizNLf7DITs>09C|Z#tNU!! z!T>xjkm8Aj{i3O^?vwfwhBaZA0K?)%Fs-X;w07cVdF8{Unklc%nO*x+$3H{`Xy)P( z7C$Z_mG(KN?)UstR5o=`_d-$Ek;?zHX;W}n)i%Xk`J(|oP(|j*k*L^CAWIj6zZ1iL zQLcv{pxInjIZ(0w$+|<${*1gsP>fldO|_e`?377z#@#*YrqT5og>KpyFBKJ)ma9Gr zIV#6nW)B<#6!p-%HO&K5qs<>e1uY*gfaiwa){*MQCtmzZVVBYi1DT`wrlE5}zLCMe z-7xY5;uEa?X}KHc)IbVJNZ4mIU2EQ%q?4lm#zfn5CGnx%fgKNMv2HzZRE8zlNaw1L z-&umY9Xii4Hv2bXg|@3RrukRjFO;<8#-)0hB6wHE@lzT*o}^ud_{l}H>aa_y@E9lS zf1bYLXnvUdRxROnmgGZC-AcpPT=*=fT6| zUA_Jx?Vgo7@+^6p>G3z|HXoeh^O44~dwW&YzHU%D6&k%QoPQp)RCD?EK%e!+*kd5D zg5kXoR&S}VSmoJva!}PZW5eKUx1pwYl66^6< zhOz6F3cRlMF%SrWFQn?Ju^`YmUr90rpcy zufn$js78cm?0*3}bYMXM literal 0 HcmV?d00001 diff --git a/scripts/find-novel.js b/scripts/find-novel.js index 525cf33..42bada0 100644 --- a/scripts/find-novel.js +++ b/scripts/find-novel.js @@ -10,6 +10,10 @@ import fs from 'fs'; import os from 'os'; const require = createRequire(import.meta.url); +// The shared fetch helper logs every request with its headers (credentials +// included); keep that out of the output. Results go to stdout explicitly. +const print = (...args) => process.stdout.write(args.join(' ') + '\n'); +console.log = () => {}; const ROOT = path.resolve(dirname(fileURLToPath(import.meta.url)), '..'); const TIMEOUT_MS = 20_000; const CONCURRENCY = 16; @@ -107,7 +111,7 @@ try { matches.sort((a, b) => rank(a) - rank(b) || a.source.localeCompare(b.source)); for (const h of matches) { - console.log( + print( `${rank(h) === 0 ? '*' : ' '} ${h.source.padEnd(28)} ${h.name}\n ${''.padEnd(28)} ${h.url}`, ); }