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 0000000..d0293fc Binary files /dev/null and b/public/static/src/multi/kavita/icon.png differ 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}`, ); }