Note
This document was generated by AI from the repository source. It is checked
against the code but may still drift — when in doubt, trust
src/types/plugin.ts and the Template plugins.
Reference for writing plugins in this repository. The authoritative contract is
src/types/plugin.ts; a fully commented working skeleton lives at
plugins/vietnamese/Template/index.ts (PluginBase) and
plugins/vietnamese/Template2/index.ts (PagePlugin).
This fork targets LNReader-eXtended. Several APIs below do not exist in the original LNReader and are marked eXtended only.
- Setup
- Plugin anatomy
- Plugin types
- Metadata fields
- Methods
- Data types
- Filters
- Plugin settings and storage
- Allowed imports
- Testing
- Custom CSS and JS
- Video plugins
- Special meta tags
Requirements: Git, TypeScript basics, Node.js >= 20 (24 recommended).
npm install
npm run devOne plugin is one folder, not a single file:
plugins/<language>/<PluginName>/
├── index.ts # entry point, default-exports a plugin instance
├── utils.ts # optional: split code across as many files as you like
├── BROKEN # optional: presence disables the plugin everywhere
└── webview/
└── index.ts # optional: source of customJS, bundled separately
<language>must match a key inscripts/languages.js, lowercased on disk (vietnamese,english,japanese,korean,multi, …).<PluginName>is PascalCase without spaces.index.tsmustexport default new YourPlugin()— an instance, not the class.- An empty file named
BROKENin the folder excludes the plugin from the dev registry, the build, the webview build, and the manifest. This replaces the old.broken.tsfilename suffix. - There is no registration step and no
plugins/index.ts. The dev UI discovers plugins withimport.meta.globoverplugins/*/*/index.ts.
Assets live under public/static/, and the plugin fields hold the path
relative to public/static/:
icon = 'src/vi/myplugin/icon.png'; // -> public/static/src/vi/myplugin/icon.pngLanguage segments in the asset path are short codes (vi, en, jp, kr,
multi). Icons should be 96x96 px.
| Type | Use when |
|---|---|
Plugin.PluginBase |
Normal sources. parseNovel returns the whole chapter list. |
Plugin.PagePlugin |
The site paginates the chapter list (e.g. 1000 chapters split into pages of 50), or you want chapters grouped into volumes. |
PagePlugin differs in two ways: parseNovel additionally returns
totalPages, and you implement parsePage(novelPath, page). PluginBase
declares parsePage?: never, so a class that implements parsePage must be
typed as PagePlugin or TypeScript will reject it.
class MyPlugin implements Plugin.PagePlugin {
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> { /* … */ }
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
return { chapters: [] };
}
}| Field | Type | Required | Description |
|---|---|---|---|
id |
string |
yes | Unique across all plugins. Must be a valid filename — the manifest build throws otherwise. |
name |
string |
yes | Display name. |
icon |
string |
yes | Path relative to public/static/. |
site |
string |
yes | Site URL. Also the URL opened in the WebView and the base URL of the reader. |
version |
string |
yes | SemVer 2.0. Bump it or the publish pipeline skips your changes (--only-new compares versions). |
filters |
Filters |
no | See Filters. |
pluginSettings |
Plugin.PluginSettings |
no | See Plugin settings. |
imageRequestInit |
Plugin.ImageRequestInit |
no | Extra method / headers / body for image requests, when a site blocks hotlinking. |
customCSS |
string |
no | Path relative to public/static/. |
customJS |
string |
no | Path relative to public/static/; built from webview/. |
contentType |
ContentType |
no | NOVEL, IMAGE, VIDEO, MIXED. eXtended only |
contentWarning |
ContentWarning |
no | UNSPECIFIED, SAFE, MIXED, NSFW. eXtended only |
webStorageUtilized |
boolean |
no | Set when the plugin needs the reader WebView's localStorage / sessionStorage (e.g. a session kept in web storage instead of cookies). |
Version bumping convention: patch for fixes that restore a broken plugin
(changed selector, filter typo), minor for improvements (new filters, search
options), major for structural changes (site URL change).
import { ContentType, ContentWarning } from '@libs/pluginMetadata';
class MyPlugin implements Plugin.PluginBase {
id = 'myplugin.id';
name = 'My Plugin';
icon = 'src/vi/myplugin/icon.png';
site = 'https://example.com';
version = '1.0.0';
contentType = ContentType.NOVEL;
contentWarning = ContentWarning.SAFE;
}popularNovels(
pageNo: number,
options: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]>Called for the plugin's front page. options.showLatestNovels marks the
"Latest" entry. When showLatestNovels is true the app sends no filters —
fall back to your own defaults instead of reading options.filters.
options.filters holds { type, value } pairs keyed the same as your filter
definition.
parseNovel(novelPath: string): Promise<Plugin.SourceNovel>
// PagePlugin: Promise<Plugin.SourceNovel & { totalPages: number }>novelPath is the path from the NovelItem you returned, and the returned
SourceNovel.path should carry the same value.
parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage>Returns the chapters of one page/volume.
parseChapter(chapterPath: string): Promise<string>Returns the chapter body as an HTML string.
searchNovels(searchTerm: string, pageNo: number): Promise<Plugin.NovelItem[]>If the site's search has no pagination, if (pageNo > 1) return [];.
resolveUrl(path: string, isNovel?: boolean): stringMaps an internal path back to a browsable URL, used by "Open in WebView".
Implement it when the site's URL layout differs from your path scheme.
Import via import { Plugin } from '@/types/plugin';
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
yes | Novel title. |
path |
string |
yes | Internal path, usually the site path without the origin. |
cover |
string |
no | Cover image URL. |
Entities use
path, noturl. Useimport { defaultCover } from '@libs/defaultCover';when a cover is missing.
NovelItem plus:
| Field | Type | Description |
|---|---|---|
genres |
string |
Comma-separated: "Action,Adventure,Comedy". |
summary |
string |
Description. |
author |
string |
|
artist |
string |
|
status |
string |
Use NovelStatus from @libs/novelStatus. |
rating |
number |
Out of 5, float. |
chapters |
ChapterItem[] |
Chapter list. |
NovelStatus values: Unknown, Ongoing, Completed, Licensed,
PublishingFinished, Cancelled, OnHiatus, STUB, Inactive.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
yes | Chapter title. |
path |
string |
yes | Internal path. |
releaseTime |
string | null |
no | "YYYY-MM-DD", an ISO string, or any display string. |
chapterNumber |
number |
no | If used, keep it unique within the novel. |
page |
string |
no | Page/volume grouping — see below. |
scanlator |
string | string[] |
no | Translation group. |
page in the original app is the page index for a PagePlugin. This fork also
accepts an arbitrary string and shows it as a volume name (Hako-style), so
page: 'Volume 1' is valid.
type SourcePage = { chapters: ChapterItem[] };import { FilterTypes, Filters } from '@libs/filterInputs';A filter definition object declares what appears in the app's filter sheet. Its keys are how you read the values back.
filters = {
genre: {
type: FilterTypes.CheckboxGroup,
label: 'Genres',
value: [],
options: [
{ label: 'Isekai', value: 'isekai' },
{ label: 'Romance', value: 'romance' },
],
},
} satisfies Filters;Do not forget
satisfies Filters— without it the value types inpopularNovelsare not inferred.
Every filter takes label, type and value (the default); the group types
also take options.
FilterTypes |
UI | value type |
|---|---|---|
TextInput |
Free text field | string |
Picker |
Single choice | string |
Switch |
Toggle | boolean |
CheckboxGroup |
Multi-select | string[] |
ExcludableCheckboxGroup |
Tri-state multi-select | { include?: string[]; exclude?: string[] } |
Reading values inside popularNovels:
options.filters.genre.value; // string[]
options.filters.genre.type; // FilterTypes.CheckboxGroup
// ExcludableCheckboxGroup
const { include, exclude } = options.filters.tags.value;pluginSettings renders user-configurable options in the app's plugin settings
screen. eXtended only. Changes take effect after reloading the app.
pluginSettings: Plugin.PluginSettings = {
hideLocked: { value: false, label: 'Hide locked chapters', type: 'Switch' },
url: { value: '', label: 'URL' }, // type defaults to 'Text'
quality: {
value: '720',
label: 'Quality',
type: 'Select',
options: [
{ label: '720p', value: '720' },
{ label: '1080p', value: '1080' },
],
},
hosts: {
value: [],
label: 'Hosts',
type: 'CheckboxGroup',
options: [{ label: 'Host A', value: 'a' }],
},
};type |
value type |
|---|---|
Text (default) |
string |
Switch |
boolean |
Select |
string + options |
CheckboxGroup |
string[] + options |
Values are read through storage, keyed by the setting name:
import { storage, localStorage, sessionStorage } from '@libs/storage';
const hideLocked = storage.get('hideLocked'); // boolean
storage.set('token', value, expiresMsOrDate); // expiry is optional
storage.delete('token');
storage.getAllKeys();
storage.clearAll();localStorage and sessionStorage expose get() only: they are snapshots
of what the reader WebView wrote, handed to the plugin by the app. Set
webStorageUtilized = true to receive them. In the playground they are filled
from the preview tab (see Testing).
Plugin sources are linted with a deny-all import rule plus an allowlist. Anything outside it is an ESLint error:
@libs/*, @/types/plugin, cheerio, htmlparser2, dayjs, urlencode,
node-html-markdown.
Available @libs modules: fetch, storage, filterInputs, novelStatus,
defaultCover, isAbsoluteUrl, utils, aes, cookie, pluginMetadata.
import { fetchApi, fetchText, fetchProto, type FetchInit } from '@libs/fetch';fetchApi(url, init)— like the Fetch API.fetchText(url, init?, encoding?)— decoded text, defaultutf-8; returns''on failure.fetchProto<T>({ proto, requestType, requestData?, responseType }, url, init?)— protobuf request/response.
These are eXtended only and ESLint warns when you import them, asking you to note the incompatibility in your README:
@libs/aes: every cipher exceptgcm(ctr,ecb,cbc,cfb,gcmsiv,aeskw,aeskwp,cmac,aessiv).@libs/utils:Buffer,NodeCrypto,getUserAgent,encodeHtmlEntities,decodeHtmlEntities.@libs/cookie(any import).
Plugins compile to ES5 for Hermes. Browser-only APIs may lint clean in the playground and still break on device.
Custom JS (the
webview/bundle) is not covered by Vite hot reload. Runnpm run build:fullafter each change.
npm run devThe only interactive way to test — there is no browser mode. It is not bound by CORS, keeps persistent cookies, and simulates the app's WebView context.
- Spawn New Tab (
Ctrl+T/Cmd+T) opens a WebView where you can solve Cloudflare/Captcha by hand; the cookies are synced to pluginfetchcalls. - Open Preview in Parse Chapter opens the chapter in its own Electron tab
with the base URL set to
plugin.site, so relative URLs resolve andcustomCSS/customJSrun the way they do in the reader. The inline panel always shows raw HTML. - Whatever the preview tab's custom JS writes to
localStorage/sessionStorageis mirrored back, which is what makes@libs/storage'slocalStorage.get()/sessionStorage.get()return data in the playground. - DevTools:
F12in the preview tab, or the debug icon in the WebView tab.
npm run serve:devPrepare .env:
USER_CONTENT_BASE=http://<your-lan-ip>:3000
Then add http://<your-lan-ip>:3000/.dist/plugins.min.json under
Settings → Repositories in LNReader on your phone (same Wi-Fi). This is the
most faithful test of Custom JS/CSS behaviour in the reader, but there is no
console unless you attach a debugger.
customJS is written in the plugin's webview/ folder (entry index.ts or
index.js) and bundled to the path in the customJS field by
npm run build:webviews. customCSS is a plain file under public/static/.
Notes on the reader environment:
- The chapter is wrapped in
<div id="LNReader-chapter">. parseChapteroutput is sanitized, so inline<script>tags may not run — put behaviour incustomJS.- The document's location is the plugin's site URL, not the chapter URL.
- See
src/lib/reader-mock.tsfor the JS context the app exposes (window.reader,window.tts,window.pageReader,window.van).
Useful reader APIs (eXtended only):
window.reader.refetch(); // force reload, bypass cache
window.reader.post({ type: 'refetch' }); // equivalent
await window.reader.fetch(url, init); // fetch without WebView restrictionsThe app ships a Core Player built on Video.js v10, with hls.js for HLS and
dash.js for DASH. It supports m3u8, mpd, plain video files and iframe,
and brings its own control skin, seek bar and fullscreen. You do not need your
own player, library or CSS — return HTML with the right <meta> tags.
When the video URL can be extracted in plugin TypeScript:
async parseChapter(chapterPath: string): Promise<string> {
const videoUrl = 'https://example.com/video.m3u8';
return [
'<meta name="lnreader-chapter-type" content="video">', // required
'<meta name="lnreader-video-mode" content="direct">',
'<meta name="lnreader-video-type" content="m3u8">',
`<meta name="lnreader-video-url" content="${videoUrl}">`,
].join('\n');
}The app builds the player and starts playback automatically.
lnreader-video-type is named after the manifest extension:
| type | plays with | notes |
|---|---|---|
m3u8 |
hls.js over MSE | Chromium has no native HLS |
mpd |
dash.js | there is no dash alias |
video-file |
plain <video> |
mp4, m4v, mkv, webm, mov, avi, ts |
iframe |
sandboxed iframe | http(s) only, cannot be downloaded |
When the URL can only be resolved by running JS in the WebView (Cloudflare, captcha, m3u8 decryption, blob URLs), or when you need to pass engine options — meta tags cannot carry them:
async parseChapter(chapterPath: string): Promise<string> {
return [
'<meta name="lnreader-chapter-type" content="video">',
'<meta name="lnreader-video-mode" content="lazy">',
'<meta name="lnreader-debug-mode" content="true">', // on-screen log overlay
].join('\n');
}Then hand the URL to the player from customJS:
(async function () {
if (!window.LNReaderPlayer) return;
const m3u8Url = await fetchVideoUrl();
window.LNReaderPlayer.playHls(m3u8Url);
})();playDirect(url)— static files (.mp4,.webm, …).playHls(url, hlsJsConfig?)—.m3u8. The second argument is thehls.jsconfig object itself, passed straight to theHlsconstructor (e.g.xhrSetupto add an Authorization header on.tsfragments).playDash(url, { settings?, protectionData? })—.mpd. See below.playIframe(url)— embed an iframe.log(msg)— console log, shown as an overlay when debug mode is on.
Note the asymmetry: playHls takes the raw hls.js config, while playDash
takes a wrapper object. playHls's shape predates the Video.js migration and is
kept for compatibility.
The live engine stays exposed for anything the wrapper does not cover:
LNReaderPlayer.hlsInstance— theHlsinstance, afterplayHls.LNReaderPlayer.dashInstance— the dash.jsMediaPlayer, afterplayDash.
await window.LNReaderPlayer.playDash(url, { protectionData });
window.LNReaderPlayer.dashInstance.updateSettings({
streaming: { buffer: { bufferTimeAtTopQuality: 30 } },
});
window.LNReaderPlayer.dashInstance.on('qualityChangeRendered', onQuality);playDirect / playHls / playDash are async — the custom elements they
need may be registered by deferred module scripts — so await them before
touching an instance, or you will read null or the previous chapter's engine.
Both are cleared when the player is torn down.
Calling updateSettings() yourself merges into the current settings, unlike
the settings option below, which replaces them.
Failures are shown to the user as an inline error banner in the reader — nothing
is silently swallowed, but nothing is thrown back at your plugin either. While
developing, read the debug overlay or attach chrome://inspect.
Full surface: src/lib/core-player.js.
settings is dash.js's own MediaPlayerSettingClass, handed over untouched:
window.LNReaderPlayer.playDash('https://example.com/manifest.mpd', {
settings: {
streaming: {
abr: { autoSwitchBitrate: { video: true } },
buffer: { bufferTimeAtTopQuality: 30 },
retryAttempts: { MediaSegment: 5 },
},
},
});Settings are replaced, not merged: the player resets dash.js settings before applying yours, so a key you drop returns to its default rather than keeping the previous value.
License servers are not part of dash.js settings — they are a separate
protectionData map, keyed by key system, passed to setProtectionData()
before the manifest is attached. Write standard dash.js field names; dash.js
silently ignores unknown keys, so a config copied from another player will
simply never send a license request and will die as a vague decode error.
window.LNReaderPlayer.playDash(
'https://media.axprod.net/TestVectors/Cmaf/protected_1080p_h264_cbcs/manifest.mpd',
{
protectionData: {
'com.widevine.alpha': {
serverURL: 'https://drm-widevine-licensing.axtest.net/AcquireLicense',
httpRequestHeaders: { 'X-AxDRM-Message': '<token>' },
priority: 0,
},
},
},
);| write this | not this |
|---|---|
serverURL |
url, licenseUrl |
httpRequestHeaders |
licenseHeaders, headers |
Other useful fields: withCredentials, httpTimeout, serverCertificate,
audioRobustness, videoRobustness, distinctiveIdentifier, persistentState.
ClearKey takes inline clearkeys instead of a serverURL.
Four constraints worth knowing before filing a bug:
- Do not set a robustness level unless you have measured that it works.
Android WebView is Widevine L3 only — it never exposes a TEE decode path
to EME, so a device that reports L1 natively still reports L3 inside a
WebView. Measured ceilings vary: on one test device an empty robustness and
SW_SECURE_CRYPTOwere accepted whileSW_SECURE_DECODEwas rejected withNotSupportedError. Leaving it unset is the safe default. - Content requiring L1 will not play, no matter how it is configured.
- Incognito blocks Widevine. It needs the device DRM identifier even at L3, and that identifier is permanent and unresettable, so the app refuses to hand it to a plugin site while incognito is on. The user sees a banner explaining it. ClearKey still works.
- DASH and DRM chapters cannot be downloaded. Encrypted fragments are useless to the download sink, so those chapters are rejected up front rather than producing an unplayable file.
If DRM fails, Chromium's It is recommended that a robustness level be specified warning is not the cause — it prints on successful calls too.
Add these to the parseChapter output.
| Tag | Effect |
|---|---|
<meta id="no-cache-marker" /> |
Do not cache this chapter. |
<meta id="no-prefetch-marker" /> |
Do not prefetch the next chapter. |
<meta id="lnreader-video-disable-progress" /> |
Video chapter without progress saving (e.g. live streams with no end time). Switches the player to the live skin and disables downloading. |
<meta name="lnreader-video-poster" content="…" /> |
Still image shown before playback starts. |
<meta name="lnreader-video-thumbnails" content="…" /> |
WebVTT storyboard for seek-bar preview images. See the warning below. |
lnreader-video-thumbnails forces crossorigin="anonymous" on the media
element. On a video-file chapter that also forces a CORS fetch of the video
itself, so a host without Access-Control-Allow-Origin stops playing. Use it
only with sources you know send CORS headers.
- Open the site in the WebView tab and solve the captcha there first.
- If the site blocks WebViews, try changing the User-Agent in settings.
- As a last resort, render the captcha inside the reader — since
parseChapteroutput is normalized, drive it fromcustomJS.