feat(multi): add Kavita plugin; keep request logs out of find output
Publish Plugins / Publish Plugins (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
masi
2026-09-25 12:58:19 -03:00
parent 053aa91b02
commit fbb91a9c84
3 changed files with 231 additions and 1 deletions
+226
View File
@@ -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<number, string> = {
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<string> {
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<Response> {
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<string, string>),
},
});
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 <T>(path: string, init?: FetchInit): Promise<T> =>
(await this.request(path, init)).json();
async bookLibraryIds(): Promise<number[]> {
const libs = await this.json<Library[]>('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<Plugin.NovelItem[]> {
const libraries = await this.bookLibraryIds();
if (!libraries.length) return [];
const series = await this.json<Series[]>(
`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<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const [libraries, result] = await Promise.all([
this.bookLibraryIds(),
this.json<SearchResult>(
`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<Plugin.SourceNovel> {
const [series, meta, volumes] = await Promise.all([
this.json<Series>(`Series/${novelPath}`),
this.json<Metadata>(`Series/metadata?seriesId=${novelPath}`),
this.json<Volume[]>(`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<TocItem[]>(`Book/${book.id}/chapters`);
const titles = new Map<number, string>();
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<string> {
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(/<style[\s\S]*?<\/style>/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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+5 -1
View File
@@ -10,6 +10,10 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
const require = createRequire(import.meta.url); 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 ROOT = path.resolve(dirname(fileURLToPath(import.meta.url)), '..');
const TIMEOUT_MS = 20_000; const TIMEOUT_MS = 20_000;
const CONCURRENCY = 16; const CONCURRENCY = 16;
@@ -107,7 +111,7 @@ try {
matches.sort((a, b) => rank(a) - rank(b) || a.source.localeCompare(b.source)); matches.sort((a, b) => rank(a) - rank(b) || a.source.localeCompare(b.source));
for (const h of matches) { for (const h of matches) {
console.log( print(
`${rank(h) === 0 ? '*' : ' '} ${h.source.padEnd(28)} ${h.name}\n ${''.padEnd(28)} ${h.url}`, `${rank(h) === 0 ? '*' : ' '} ${h.source.padEnd(28)} ${h.name}\n ${''.padEnd(28)} ${h.url}`,
); );
} }