Skip to content

Latest commit

 

History

History
641 lines (497 loc) · 22.4 KB

File metadata and controls

641 lines (497 loc) · 22.4 KB

LNReader Plugin Development

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

Requirements: Git, TypeScript basics, Node.js >= 20 (24 recommended).

npm install
npm run dev

Plugin anatomy

One 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 in scripts/languages.js, lowercased on disk (vietnamese, english, japanese, korean, multi, …).
  • <PluginName> is PascalCase without spaces.
  • index.ts must export default new YourPlugin() — an instance, not the class.
  • An empty file named BROKEN in the folder excludes the plugin from the dev registry, the build, the webview build, and the manifest. This replaces the old .broken.ts filename suffix.
  • There is no registration step and no plugins/index.ts. The dev UI discovers plugins with import.meta.glob over plugins/*/*/index.ts.

Icons and assets

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.png

Language segments in the asset path are short codes (vi, en, jp, kr, multi). Icons should be 96x96 px.

Plugin types

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: [] };
  }
}

Metadata fields

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;
}

Methods

popularNovels

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

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 — PagePlugin only

parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage>

Returns the chapters of one page/volume.

parseChapter

parseChapter(chapterPath: string): Promise<string>

Returns the chapter body as an HTML string.

searchNovels

searchNovels(searchTerm: string, pageNo: number): Promise<Plugin.NovelItem[]>

If the site's search has no pagination, if (pageNo > 1) return [];.

resolveUrl — optional

resolveUrl(path: string, isNovel?: boolean): string

Maps 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.

Data types

Import via import { Plugin } from '@/types/plugin';

NovelItem

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, not url. Use import { defaultCover } from '@libs/defaultCover'; when a cover is missing.

SourceNovel

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.

ChapterItem

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.

SourcePage

type SourcePage = { chapters: ChapterItem[] };

Filters

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 in popularNovels are 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;

Plugin settings and storage

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).

Allowed imports

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, default utf-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 except gcm (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.

Testing

Custom JS (the webview/ bundle) is not covered by Vite hot reload. Run npm run build:full after each change.

Electron playground

npm run dev

The 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 plugin fetch calls.
  • 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 and customCSS / customJS run the way they do in the reader. The inline panel always shows raw HTML.
  • Whatever the preview tab's custom JS writes to localStorage / sessionStorage is mirrored back, which is what makes @libs/storage's localStorage.get() / sessionStorage.get() return data in the playground.
  • DevTools: F12 in the preview tab, or the debug icon in the WebView tab.

Testing on the real app

npm run serve:dev

Prepare .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.

Custom CSS and JS

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">.
  • parseChapter output is sanitized, so inline <script> tags may not run — put behaviour in customJS.
  • The document's location is the plugin's site URL, not the chapter URL.
  • See src/lib/reader-mock.ts for 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 restrictions

Video plugins

The 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.

Direct mode

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

Lazy mode

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

window.LNReaderPlayer

  • playDirect(url) — static files (.mp4, .webm, …).
  • playHls(url, hlsJsConfig?).m3u8. The second argument is the hls.js config object itself, passed straight to the Hls constructor (e.g. xhrSetup to add an Authorization header on .ts fragments).
  • 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.

Reaching the engine directly

The live engine stays exposed for anything the wrapper does not cover:

  • LNReaderPlayer.hlsInstance — the Hls instance, after playHls.
  • LNReaderPlayer.dashInstance — the dash.js MediaPlayer, after playDash.
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.

Configuring dash.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.

DRM

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_CRYPTO were accepted while SW_SECURE_DECODE was rejected with NotSupportedError. 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.

Special meta tags

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.

Captcha and blocked sites

  1. Open the site in the WebView tab and solve the captcha there first.
  2. If the site blocks WebViews, try changing the User-Agent in settings.
  3. As a last resort, render the captcha inside the reader — since parseChapter output is normalized, drive it from customJS.