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';
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user