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();