feat(multi): add Kavita plugin; keep request logs out of find output
Publish Plugins / Publish Plugins (push) Has been cancelled
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:
@@ -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 |
@@ -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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user