Plugin Development
This guide is for plugin developers. It covers plugin directories, the Manifest, permissions, runtime APIs, resource models, download plans, WASM processors, debugging, testing, and publishing. For installing and managing plugins as a user, see Plugin Management.
res-downloader identifies its plugin protocol by version. The app passes captured request and response information to plugins, which return resources in the agreed data format. Plugins cannot directly access Go objects, the filesystem, or the shell, or make arbitrary network requests themselves.
The repository's plugins/ directory is a local workspace for cloning and debugging independent plugin repositories. By default, these sources are not committed to the host repository, loaded automatically, or packaged with the app. Example plugins live in examples/plugins/; bundled plugins shipped with the app live in internal/plugin/bundled/.
Before developing a plugin, read “Choose a plugin type → Quick start → Manifest → Permissions → Resource model → JavaScript hooks or Declarative plugins → Validation and offline replay.” Page scripts, download plans, and WASM are advanced capabilities to use as needed.
Supported capabilities include:
declarative: extract a single-track resource from one JSON response using JSON/YAML and a restricted JSON Path syntax.javascript: process complex JSON, correlate multiple requests, define download plans, and refresh resources.- Plugin-provided WASM: run private decryption or transformation algorithms on download inputs or outputs.
- Resource actions: process local files or send plugin-defined arguments to matching page scripts.
- Host downloads: ordinary HTTP and HLS, plus audio/video merging, remuxing, audio extraction, and live recording when FFmpeg is configured.
Choose a plugin type
| Scenario | Recommendation |
|---|---|
| One JSON response directly contains the title and download URL | declarative |
| Conditional logic, complex object traversal, or multiple quality options | javascript |
| Video and audio arrive in separate requests and must be paired by content ID | javascript + correlation APIs |
| Links expire and must be resolved again before download | javascript + refreshResource |
| Private decryption or byte transformations after downloading | JavaScript + plugin WASM |
| Player data must be read from the page's runtime environment | JavaScript + page scripts; use only when ordinary observation APIs are insufficient |
Choose the simplest implementation that works. If the response JSON is sufficient, do not request page script, response modification, or advanced FFmpeg permissions.
Quick start
1. Copy an example
Run from the repository root:
cp -R examples/plugins/javascript-basic ./plugins/com.example.my-pluginFor a simple JSON API, copy declarative-basic. The WASM example is wasm-xor. You can also use the project CLI to create a JavaScript scaffold interactively:
go run main.go plugin createPress Enter at every prompt to create ./plugins/com.example.my-plugin, with both the ID and display name set to com.example.my-plugin. You can also provide arguments directly:
go run main.go plugin create ./plugins/com.example.my-plugin com.example.my-plugin "Example Video"The scaffold contains plugin.json, main.js, README.md, .gitignore, an empty fixtures/ directory, and .github/workflows/release.yml calling the shared release workflow.
2. Edit the Manifest and entry point
Change the plugin id, name, version, domains, and matching rules. Use a stable reverse-domain ID such as com.example.video. Community plugins cannot use the case-insensitive builtin. or official. prefixes; see Plugin sources and reserved IDs.
JavaScript plugins can implement onObservation in main.js. Start by matching one response and reporting one resource, then add correlation, refresh, or download processing as needed.
3. Prepare a sanitized fixture
Save a representative request/response as a fixture. Remove cookies, Authorization headers, account details, signatures, and real content. Keep only the fields needed for the plugin's decisions.
4. Static validation and offline replay
go run main.go plugin lint ./plugins/com.example.my-plugin
go run main.go plugin replay ./plugins/com.example.my-plugin ./plugins/com.example.my-plugin/fixtures/video.jsonlint checks the directory, Manifest, entry files, and permission dependencies. replay executes a fixture without starting the proxy.
5. Package and install
go run main.go plugin pack ./plugins/com.example.my-pluginThe default output is <plugin-directory>/dist/plugin.zip; append another output path if needed. Install the ZIP under Plugins in the app. During development, you can also place the plugin directory in the app data directory's plugins/ folder and click Reload. See Other distribution methods for local ZIP installation rules.
Development requirements
- Least privilege: declare only the domains and capabilities you need; avoid
*domains. - Minimal bodies: narrow the
matchrules, setreadBody: falsewhen bodies are unnecessary, and choose the smallest sufficientbodyLimit. - Sensitive data: do not log or report cookies, Authorization headers, account details, administrator passwords, or long-lived credentials. Use
nonPersistentHeadersfor headers that should not be saved. - Reliable correlation: associate requests using content IDs, track IDs, or explicit aliases. Do not guess based on arrival order.
- Clear failures: return an explicit refresh status for expired URLs. Keep incomplete resources non-downloadable rather than producing invalid files that appear successful.
- Untrusted page messages: page scripts share the website's environment. Validate types, lengths, and business fields in every bridge message.
- Lawful use: plugins must not bypass access controls or unauthorized DRM. Fixtures and logs must not contain other people's private data.
Plugin directories and loading
Each plugin uses its own directory:
plugins/
└── com.example.video/
├── plugin.json
├── main.js
├── decrypt.wasm
├── fixtures/
│ └── video.json
└── tests/
└── main.test.jsThe Manifest can be plugin.json, plugin.yaml, or plugin.yml. In a ZIP, it must be at the archive root or inside its single top-level folder. Entry and asset files must use paths relative to the plugin directory. Symlinks, paths escaping the directory, and oversized files are rejected.
Plugins load when the app starts. During development, click Reload to load changes. Enabling or disabling plugins and saving plugin settings also refresh the active plugin set. The directory name should match the Manifest id.
App upgrades overwrite official plugins. To customize one, copy it with a new plugin ID.
Keep JavaScript tests in tests/ at the plugin repository root, preferably named *.test.js, and offline replay data in fixtures/.
The packer applies these fixed exclusions without reading .gitignore:
- Directories:
.git/,.github/,.idea/,.vscode/,dist/, andtests/. - Files: the output ZIP itself, plus
.gitignore,.DS_Store,README.md, andLICENSE.
Manifest
For JSON, use the Manifest Schema and editor declarations for field assistance:
id: com.example.video
name: Example Video
author:
name: Example Developer
url: https://example.com
version: 1.0.0
apiVersion: 1
runtime: javascript
entry: main.js
priority: 100
locales:
zh:
name: 示例视频插件
description: 从示例站点发现视频资源。
en:
name: Example Video
description: Discovers videos from the example site.
permissions:
domains: [api.example.com, "*.cdn.example.com"]
capabilities:
- observe-response
- read-response-body
- emit-resource
bodyLimit: 4194304
match:
- stage: response
host: api.example.com
path: /api/videos/*
readBody: true
contentTypes: [application/json, "application/json;*"]
resourceKinds:
- id: media.video
icon: video
color: "#2080f0"
locales:
zh: {name: 视频}
en: {name: Video}
settingsSchema:
type: object
properties:
enableLog:
type: boolean
default: false
x-locales:
zh: {name: 启用日志, description: 记录插件调试日志,排查问题时开启。}
en: {name: Enable logging, description: Record plugin debug logs for troubleshooting.}
minimumSize:
type: number
default: 0
x-locales:
zh: {name: 最小大小, description: 小于该字节数的资源不会被插件输出。}
en: {name: Minimum size, description: Resources smaller than this byte count are ignored.}Main fields:
| Field | Required | Description |
|---|---|---|
id | Yes | Up to 64 characters: letters, digits, dots, hyphens, and underscores only. Reserved prefixes are case-insensitive; see Plugin sources and reserved IDs |
name | Yes | Plugin name shown when localized text is unavailable |
author | No | name appears on the plugin card; url must be an HTTP/HTTPS address |
version | Yes | Semantic version, for example 1.0.0 |
apiVersion | Yes | Must currently be 1 |
runtime | Yes | javascript or declarative |
entry | For JavaScript | A .js entry inside the plugin directory, up to 1 MiB |
priority | No | Higher values run first; multiple plugins may process the same request |
permissions | Yes | Permitted domains, capabilities, and body size limit |
match | Yes | Request or response matching rules; an empty array matches all permitted stages within the permitted domains |
locales | No | Translations of the plugin name and description |
resourceKinds | No | Site-specific resource kinds and display labels |
settingsSchema | No | Plugin setting structure, defaults, and form hints |
pageScripts | No | Scripts injected by the host into matching HTML pages |
extractors | For declarative | Rules for extracting resources from JSON bodies |
processors | No | Plugin-provided WASM processors |
operations | No | Independent business capabilities and schemas; see Operations |
actions | No | Local file processing or business operation references displayed by the host |
requires | No | Optional host tool requirements, such as ffmpeg: ">=6.0" |
In match, host, path, and the full url support * wildcards; method is case-insensitive. contentTypes matches the response Content-Type. readBody determines whether a matching rule needs the body. Set readBody: false explicitly when only the URL or response headers are needed.
resourceKinds appear among the capture types on the main page. Users can filter broadly by the stable primaryType or more precisely by the plugin's kind.
settingsSchema supports basic validation for string, number, integer, boolean, object, array, and enum. Basic types generate forms in plugin management; complex structures can use the advanced JSON editor.
The app currently supports Chinese (zh) and English (en). Use these two language keys in plugin locales, setting x-locales, and enum x-enumLabels, and provide text for both languages.
Use x-locales for setting names and descriptions, and x-enumLabels for enum option labels:
quality:
type: string
enum: [default, high, low]
default: default
x-locales:
zh: {name: 下载画质, description: 选择插件请求资源时使用的画质。}
en: {name: Download quality, description: Select the quality requested by the plugin.}
x-enumLabels:
zh: {default: 默认, high: 高清, low: 低清}
en: {default: Default, high: High, low: Low}Permissions
| Capability | Purpose | Prerequisites or notes |
|---|---|---|
observe-request | Receive request URLs, headers, and other metadata | Restricted to permissions.domains and matching rules |
read-request-body | Read matching request bodies | Requires observe-request; request only when necessary |
intercept-request | Return a locally generated response | Requires observe-request; changes page behavior |
observe-response | Receive response status, headers, and other metadata | Basic permission for resource discovery |
read-response-body | Read matching response bodies | Requires observe-response; bounded by bodyLimit |
modify-response | Modify matching responses | Requires observe-response; truncated bodies cannot be modified |
emit-resource | Report resources | Output is still validated by the host |
process-download | Invoke WASM processors declared by this plugin | Cannot select arbitrary local files or modules |
media.basic | Use controlled operations such as mux, remux, and audio extraction | Requires compatible user-configured FFmpeg |
media.ffmpeg | Use the advanced FFmpeg argument-array interface | No shell; requires FFmpeg |
media.ffmpeg.network | Allow FFmpeg to read plugin-provided HLS/live URLs | Download URLs must be valid HTTP/HTTPS addresses; this is a sensitive permission |
inject-page-script | Inject scripts into matching HTML pages | Target domains must undergo TLS interception and allow safe injection |
page-bridge | Exchange JSON between page scripts and the plugin runtime, or execute declared business operations | Requires inject-page-script |
capture-response-body | Cache Range responses, page segments, or plugin-generated files through api.capture.save | Requires observe-response; page segments also require inject-page-script and page-bridge |
enqueue-download | Automatically create downloads after a page message reports resources | Requires page-bridge and emit-resource; sensitive because it writes to the download directory |
Bodies reach plugins only when the domain, matching rule, and read permission all allow it. Data exceeding bodyLimit is marked truncated; plugins cannot modify truncated responses.
Each JavaScript hook call runs in an independent environment. The main limits are:
| Item | Limit |
|---|---|
| Script size | Up to 1 MiB |
| Execution time | Up to 5 seconds, including environment initialization, hook execution, and conversion of the returned result |
| Total time including queuing | Up to 10 seconds, including time waiting for other calls to finish |
| Concurrent calls | Up to 4 per plugin |
Cancelling a request interrupts its execution. Timed-out calls still occupy a concurrency slot until they exit. Consecutive failures suspend plugin execution; fix the issue and reload.
The plugin environment provides no Node.js, fetch, filesystem, or system-command APIs, and cannot wait for Promises. Use the app's correlation APIs to retain associations across requests. JavaScript globals are not retained between calls.
Page scripts and the bidirectional message bridge
JavaScript plugins declare scripts to inject into pages through pageScripts. The app's MITM proxy performs the injection, so TLS interception must be enabled for the target domain.
Page scripts are not subject to plugin hook body or execution limits and can use browser async APIs, subject to CSP, same-origin policy, and bridge restrictions. Only document-start is supported. Set frames to top (the default, top-level pages only) or all (including subframes).
{
"permissions": {
"domains": ["www.example.com"],
"capabilities": ["inject-page-script", "page-bridge"]
},
"pageScripts": [{
"id": "runtime-hook",
"entry": "page/inject.js",
"match": [{"host": "www.example.com", "path": "/watch*"}],
"runAt": "document-start",
"frames": "top",
"bridge": true
}]
}Each entry must be a regular .js file inside the plugin directory, up to 256 KiB. A plugin may declare at most 8 page scripts.
The app checks only the first 256 KiB of HTML and prefers the page's existing CSP nonce. It injects scripts only when the response is uncompressed text/html, a <head> can be found, and the page's CSP allows inline scripts. Otherwise, it preserves the original response. It does not remove or relax the site's CSP.
The page entry runs inside an async function with a local pageApi:
pageApi.onMessage(function (message) {
if (message.type === "probe") runProbe(message.url)
})
var result = await pageApi.send({type: "player-ready", data: collectPlayerData()})With capture-response-body declared, a page script can use its current page session token to write file bytes or media segments into the app's capture cache (Capture Store):
await pageApi.capture.start("video:123:video")
await pageApi.capture.write("video:123:video", arrayBufferOrTypedArray)
await pageApi.capture.complete("video:123:video")
// On cancellation or failure:await pageApi.capture.abort("video:123:video")The four methods serve these purposes:
start: begin capturing, clearing any existing cache with the same name.write: append data in the order each call completes.complete: mark capture as complete so thatcapture-filecan read it.abort: cancel capture and immediately delete the incomplete cache.
The page script must order segments for playback and remove duplicates. The app does not parse the site's private streaming protocol. A segment may be at most 32 MiB. Each page session may use up to 4 capture keys simultaneously and write up to 16 GiB in total.
Writes require a valid page Origin, session token, and plugin permissions, and can access only the current plugin's cache.
Each page load receives its own pageSessionId. Closing the page or reloading, disabling, or uninstalling the plugin invalidates the session.
Plugins handle page messages through a synchronous top-level hook:
function onPageMessage(message, context, api) {
if (message.type !== "player-ready") return {ok: false, error: "unsupported message"}
api.page.send(context.pageSessionId, {type: "probe", url: message.data.url})
api.upsert(/* ResourceCandidate */)
return {ok: true, data: {accepted: true}}
}With enqueue-download, return autoDownload: true in a page message result to create tasks for resources in that same result that passed validation, were published, and have stable groupKey values, up to 4 per call. Incomplete resources or invalid plans prevent queuing. Webpages cannot specify file paths or control other tasks:
return {
ok: true,
resources: [resource],
autoDownload: true
}context provides pageSessionId, scriptId, pageUrl, origin, and the current plugin settings.
settingsis passed only toonPageMessagein the app, not directly to the webpage or throughapi.page.sessions(). If a page needs settings, the plugin can return necessary nonsensitive options in an initialization reply, such as whether to show a button.pageUrlupdates throughoperations.setState. After SPA navigation, the page script must still verify the current item or resource.- Plugins with
page-bridgemay also callapi.page.broadcast(filter, message)andapi.page.sessions(filter).
Ordinary messages and replies must be JSON, up to 64 KiB each. Use pageApi.capture.write for larger media data. Each plugin may retain up to 32 active page sessions, with additional limits on each session's message queue, connection count, and request rate.
Use pageApi.operations to register handlers and business readiness. The host selects an explicit page and manages execution, results and cancellation; see Plugin operations.
Page scripts and website code run in the same webpage JavaScript environment. Website code may read or imitate bridge requests, so plugins must validate incoming page messages.
Writing and reporting a file from a page
This example uses the runtime-hook page script and www.example.com domain from this section's Manifest. In addition to inject-page-script and page-bridge, declare observe-response, capture-response-body and emit-resource.
The page entry writes a complete file before notifying the plugin. Encode text as UTF-8; for images, audio, video and PDF, write the complete bytes of the actual file:
const key = "example:caption:1"
const bytes = new TextEncoder().encode("Example title\n\nExample caption.")
await pageApi.capture.start(key)
await pageApi.capture.write(key, bytes)
await pageApi.capture.complete(key)
await pageApi.send({type: "example-file-ready", key, size: bytes.length})The plugin entry validates the message source, cache key and byte count before reporting a resource for preview and download. This example limits caption size to 64 KiB:
function onPageMessage(message, context, api) {
if (context.scriptId !== "runtime-hook" ||
context.origin !== "https://www.example.com" ||
!message || message.type !== "example-file-ready" ||
message.key !== "example:caption:1" ||
typeof message.size !== "number" || message.size <= 0 ||
message.size > 65536 || Math.floor(message.size) !== message.size) {
return {ok: false, error: "invalid file"}
}
api.emit({
groupKey: "example:caption", kind: "document.text", primaryType: "document",
title: "Example caption", capabilities: ["download", "preview"],
tracks: [{id: "text", role: "primary", executor: "capture-file",
captureKey: message.key, size: message.size,
extension: ".txt", mime: "text/plain; charset=utf-8"}],
requiredTracks: ["primary"],
preview: {renderer: "text", trackId: "text", mime: "text/plain; charset=utf-8"}
})
return {ok: true}
}In a real plugin, assign cache keys and groupKey values per resource so that different files do not overwrite or merge with each other. See Previewing captured files for download plans, renderer selection and cache access rules. If the file content is already available in a plugin hook, use Saving files directly from plugins.
Resource model
Plugins report a logical resource. An ordinary file usually has one track. Sites with separate audio and video may provide several video, audio, or subtitle tracks and multiple quality options.
api.emit({
groupKey: "video:" + payload.id,
kind: "media.video",
primaryType: "video",
traits: ["multiTrack", "mergeRequired"],
title: payload.title,
coverUrl: payload.cover,
tracks: [{
id: "video-1080p",
role: "video",
executor: "http-file",
url: payload.videoUrl,
mime: "video/mp4",
extension: ".mp4",
quality: "1080p",
width: 1920,
height: 1080,
headers: {Referer: observation.request.url},
// Only affects persisted copies in resources.db/tasks.db; this session can still download and preview.
nonPersistentHeaders: ["Cookie"]
}],
requiredTracks: ["video"],
capabilities: ["download", "preview", "open", "copy"],
preview: {
renderer: "video",
mode: "proxy",
mime: "video/mp4",
trackId: "video-1080p"
},
metadata: {
"example.assetId": payload.id,
author: payload.author
}
})Resource fields:
primaryType: a stable main type, limited tovideo,audio,image,document,archive,collection, andother.kind: a required plugin-specific kind, not restricted to a core enum.traits: features that can be combined, such asencrypted,multiTrack,segmented,streaming,live,gallery, andmergeRequired. Useplugin.id:namefor plugin-private traits.groupKey: a stable identifier for the same resource within one plugin. Required when combining resource information from multiple requests.parentGroupKey: the optional parent resource'sgroupKey. The resource appears as a child in the parent's expanded row. Downloading the parent downloads all downloadable children.parentId: the parent resource ID resolved and persisted by the core. Plugins cannot set it; the core clears it from plugin output to prevent attaching resources across plugins.dedupeKey: an optional custom deduplication key. Usually let the core derive it frompluginId + groupKeyor the primary track URL.tracks: tracks to download separately. Eachidis unique within the resource,roleidentifies its purpose such as video or audio, andexecutordefaults tohttp-file.requiredTracks: roles required forreadystatus. If any is missing, the resource ispartialand cannot be downloaded from the UI.capabilities: determines which action buttons are available. Current generic values aredownload,preview,open, andcopy.preview: selects a trusted frontend renderer and the track to preview. Plugins cannot inject frontend code.coverUrl: an optional cover URL. Do not put large Base64 images into resources.technical: optional MIME, container, codec, and duration information for display or naming.lifecycle.expiresAt: an optional millisecond timestamp for the expected URL expiration time.metadata: namespace site-private fields. The genericauthor,createdAt, andpublishedAtfields are available to filename templates.actions: references host actions declared in the Manifest, such as WASM local file processing or business operations. Dynamic arguments are stored in the action'sdata.
Use metadata.createdAt for creation time and metadata.publishedAt for publication time. Both are optional Unix millisecond timestamps: finite integer number values greater than 0 and no greater than 253402300799999. Convert site timestamps in seconds to milliseconds, and parse date strings using the site's explicit time zone. Do not use strings, seconds, capture time, or publication time as a substitute for creation time.
metadata: {
author: "Example author",
createdAt: 1788220800000, // 2026-09-01T00:00:00Z
publishedAt: 1788307200000 // 2026-09-02T00:00:00Z
}Both fields can be used in filenames; see Filename template.
Resource output must also meet these constraints:
groupKeyandparentGroupKeyare at most 512 bytes. A child cannot be its own parent.- A collection without tracks must have a stable
groupKey. track.idis unique within a resource. Track URLs must be valid remote addresses;capture-fileusescaptureKeyinstead.- Extensions start with
.and are at most 20 characters. preview.trackIdmust reference a track in the current resource.- A serialized resource cannot exceed 1 MiB. Do not put complete response bodies into
metadata.
A collection parent should use media.collection, a stable groupKey, and the download capability, without tracks. Independent outputs such as images or audio should be child resources with parentGroupKey. Do not put them in the parent's tracks, which represent inputs needed to produce one output. Deleting a parent also deletes its children; deleting one child does not affect its siblings.
preview.renderer supports image, audio, video, pdf, and text. Preview behavior depends on the resource:
- Resources requiring WASM processing: the preview request supplies only the app's resource ID. The backend selects the input using
trackIdand runs the WASM processors declared on that input and the output, allowing resources that need decryption to be previewed. - Ordinary images: without processors, the WebView loads them directly first, avoiding image CDNs that reject Go TLS connections. If that fails, the backend requests the image using the captured headers.
- HLS: master playlists, media playlists, segments, initialization segments, and AES key addresses are converted to local addresses with short-lived tokens. The backend forwards requests with the track's headers, so the source site needs no additional CORS configuration. The preview endpoint can access only these registered addresses; it is not an arbitrary-URL proxy.
Incremental aggregation and correlation
api.upsert(resource) and api.emit(resource) behave the same way. Reports from the same plugin with the same groupKey merge into one resource, updating tracks by track.id.
Do not pair audio and video by request arrival order. First obtain the content ID and download addresses from the site's API, then register URLs that may refer to the same resource or track as aliases:
api.correlate.register({
groupKey: "video:" + payload.id,
trackId: "audio-default",
role: "audio",
aliases: payload.audioUrls
})
api.correlate.find(observation.request.url).forEach(function (ref) {
api.upsert({
groupKey: ref.groupKey,
kind: "media.video",
tracks: [{
id: ref.trackId,
role: ref.role,
url: observation.request.url
}]
})
})Each plugin has its own correlation table. One URL may be associated with several resources or tracks, and records have expiration times and a count limit. The app performs only basic URL normalization; it does not decide which signature parameters the plugin may remove. If the relationship is uncertain, report separate resources or keep the resource incomplete.
JavaScript hooks
The JavaScript runtime supports four synchronous top-level global functions. Implement those your plugin needs:
function onObservation(observation, api) {
var payload = JSON.parse(observation.response.body)
api.log("found " + payload.id)
api.emit(/* Resource */)
return {decision: "continue", handled: true}
}
function createDownloadPlan(input, api) {
api.log("Creating download plan; plugin version: " + api.pluginVersion)
var track = input.resource.tracks[0]
return {
inputs: [{
id: track.id,
executor: track.executor || "http-file",
url: track.url,
headers: track.headers || {},
extension: track.extension || "",
processors: track.processors || []
}],
output: {input: track.id, extension: track.extension || ""}
}
}observation.settings and input.options.settings contain the plugin settings saved in the app. api.pluginVersion is the Manifest version. decision can be continue or stop.
The API argument for each hook is:
| Hook | API argument |
|---|---|
onObservation(observation, api) | Full PluginAPI |
onPageMessage(message, context, api) | Full PluginAPI |
createDownloadPlan(input, api) | Basic PluginBaseAPI, containing only log and pluginVersion |
refreshResource(input, api) | Basic PluginBaseAPI, containing only log and pluginVersion |
The basic API needs no additional permission. Submit download plans and refresh results through return values.
Observation structure
onObservation(observation, api) receives this JSON structure:
interface Observation {
stage: "request" | "response"
request: {
method: string
url: string
host: string
path: string
headers: Record<string, string[]>
body?: string
truncated?: boolean
}
response?: {
statusCode: number
headers: Record<string, string[]>
contentType: string
body?: string
truncated?: boolean
}
settings?: Record<string, unknown>
}Header values are always string arrays; do not assume a single value. The request stage has no response. A body is provided only when a matching rule allows reading and the plugin has the corresponding body permission. truncated is true when bodyLimit is reached.
Before parsing JSON, check that response exists, its status and Content-Type are appropriate, the body is not empty, and it is not truncated. Do not treat truncated content as complete JSON or media data.
onObservation return value
A hook can report resources through api.emit / api.upsert, or return:
interface PluginResult {
decision?: "continue" | "stop"
handled?: boolean
resources?: ResourceCandidate[]
patch?: {statusCode?: number; headers?: Record<string, string>; body?: string}
syntheticResponse?: {statusCode: number; headers?: Record<string, string>; body?: string}
captures?: Array<{key: string; mode?: "range-file"}>
diagnostics?: string[]
}handled: truemeans the site plugin has taken responsibility for the response. Other site plugins still run, but the finalbuiltin.generic-detectoris skipped to avoid duplicate generic resources. Leave it unset when only logging diagnostics or modifying a response.decision: "stop"immediately stops subsequent plugins. Use it only when exclusive handling is needed.patchrequiresmodify-response;syntheticResponserequiresintercept-request.diagnosticsis for sanitized development diagnostics and must not contain request credentials or complete private URLs.
Response modification and request interception
Modifying a response requires modify-response. Truncated bodies cannot be modified:
return {
patch: {
body: modifiedBody,
headers: {"X-Plugin": "com.example.video"}
}
}Request interception uses syntheticResponse and requires intercept-request.
Runtime API
api.log and api.pluginVersion are available in all four hooks. The other APIs below are available only in onObservation and onPageMessage.
| API | Returns | Description |
|---|---|---|
api.emit(resource) | void | Report a resource, using the same merge behavior as upsert |
api.upsert(resource) | void | Recommended for incremental resource updates with a stable groupKey |
api.capture.save(data) | {captureKey, size} | Save UTF-8 strings or binary bytes synchronously; requires capture-response-body, without a page session |
api.log(message) | void | Write a log only when the current plugin's enableLog setting is boolean true; the caller must sanitize it |
api.pluginVersion | string | Current Manifest version |
api.correlate.register(value) | void | Associate URL aliases with resources and tracks |
api.correlate.find(url) | ResourceReference[] | Look up associations registered by the current plugin |
api.page.send(sessionId, message) | boolean | Send a message to one page session; requires page-bridge |
api.page.broadcast(filter, message) | number | Broadcast to matching pages and return the number of recipient sessions |
api.page.sessions(filter?) | PageMessageContext[] | List page sessions visible to the current plugin |
Reported resources must pass host validation; see Resource model for field constraints.
Declare enableLog in settingsSchema.properties as a boolean with default false and Chinese and English labels. Only true enables api.log(); plugins need not check the switch themselves. Plugin load and execution errors are still logged. See application logs for log locations.
Resource refresh
Implement refreshResource(input, api) when URLs or request credentials expire. Like createDownloadPlan, it receives {resource, options} and the base API:
return {
status: "refreshed",
resource: updatedResource,
message: ""
}| status | Returned resource |
|---|---|
refreshed | Complete updated resource |
authenticationRequired | Original resource; sign-in required |
recaptureRequired | Original resource; reopen the page to capture again |
Use message for an optional sanitized hint. An absent implementation or null result means refresh is unsupported. Plugins cannot make their own network requests; typically obtain new data through page scripts or fresh network observations and update the resource with a stable groupKey.
Saving files directly from plugins
onObservation and onPageMessage can call api.capture.save(data) to synchronously save text or binary files. The capture-response-body permission is required; otherwise api.capture is absent.
This example requires observe-response, capture-response-body, and emit-resource, with target domains and matching rules. A real plugin should extract content from the response and generate a stable groupKey per resource:
function onObservation(observation, api) {
if (observation.stage !== "response" || observation.response.statusCode !== 200) return
var file = api.capture.save("Example title\n\nExample caption.")
// Binary files can use ArrayBuffer, Uint8Array or Uint8ClampedArray.
api.emit({
groupKey: "example:caption", kind: "document.text", primaryType: "document",
title: "Example caption", capabilities: ["download", "preview"],
tracks: [{id: "text", role: "primary", executor: "capture-file",
captureKey: file.captureKey, size: file.size,
extension: ".txt", mime: "text/plain; charset=utf-8"}],
requiredTracks: ["primary"],
preview: {renderer: "text", trackId: "text", mime: "text/plain; charset=utf-8"}
})
}This single-track file resource can use the host's default download plan without implementing createDownloadPlan. See Previewing captured files for MIME types, renderers and custom download plan requirements.
Strings are encoded as UTF-8; byte views preserve their exact view bounds and bytes. Empty data, ordinary arrays, objects and other numeric TypedArrays are rejected. Each hook may save up to eight files and 32 MiB in total, counting failed writes. Time spent saving counts toward the hook execution time limit. Invalid inputs, exceeded limits, unavailable storage and I/O failures throw. Incomplete writes are aborted on a best-effort basis and produce no successful result.
The returned captureKey is random and scoped to the plugin; use it unchanged in capture-file tracks and download inputs. size is the actual byte count, and the file is complete when the call returns. Each save creates a new key. If the hook later fails or reports no resource, saved files are not rolled back and follow the cache expiry rules.
CLI fixture replay uses a temporary capture store and removes it afterward without touching the app's cache.
Capturing response bodies
To reuse response bodies already received by the browser, return a capture instruction from the response hook. Cache keys are scoped to the plugin:
return {
decision: "continue",
captures: [{key: "asset:" + payload.id, mode: "range-file"}],
resources: [{
groupKey: "asset:" + payload.id,
kind: "media.video",
tracks: [{
id: "video",
role: "video",
executor: "capture-file",
captureKey: "asset:" + payload.id,
extension: ".mp4"
}],
requiredTracks: ["video"],
capabilities: ["download"]
}]
}range-file merges byte ranges using the response Content-Range, request Range, or range and clen in the URL. If data is incomplete, capture-file refuses the download; load the remaining data and retry. See Previewing captured files for cache size and expiry rules.
Previewing captured files
capture-file is a generic file source for text, images, audio, video and PDF. Files can come from direct plugin saves, page writes or response body capture. The plugin needs capture-response-body, and cache keys are scoped to the plugin.
When reporting a resource, declare the download and preview capabilities, set the track's executor to capture-file, and provide its captureKey, actual extension and MIME type. Reference that track in preview.trackId, and choose preview.renderer and preview.mime for the actual file:
| File | renderer | Example MIME type |
|---|---|---|
| TXT | text | text/plain; charset=utf-8 |
| PNG | image | image/png |
| M4A | audio | audio/mp4 |
| MP4 | video | video/mp4 |
pdf | application/pdf |
A single-track file resource can use the host's default download plan. When implementing createDownloadPlan, retain the preview track's ID, executor: "capture-file" and captureKey in inputs, and use output.input to select the input or processed result to save. A separate preview track must also be a plan input, not just a resource track. Do not provide a URL for a captured-file input.
Preview supports GET, HEAD, single/multiple and suffix byte ranges, using the declared MIME type. The browser must support the actual format and codecs. Missing or incomplete captures return 409 and require recapture. A cache being read cannot be overwritten; writers should retry later or use a new capture key. External playlist segments are not automatically included in the cache.
Captured previews with input or output processors transform the complete file before serving ranges of the resulting representation. This path accepts at most 4 MiB of input and returns 422 above that limit; normal processed downloads remain available. Unprocessed files have no such preview input limit.
Each cached object may be at most 16 GiB, with additional count and byte limits on write APIs. On startup, the app clears caches older than 24 hours. Previews use resource IDs, not arbitrary file paths or another plugin's cache.
Download plans
createDownloadPlan(input, api) generates a download plan. It specifies the files or tracks to download, processing steps such as merging or decryption, and the final output:
return {
inputs: [
{id: "part-1", executor: "http-file", url: payload.part1},
{id: "part-2", executor: "http-file", url: payload.part2}
],
pipeline: [{
id: "joined",
executor: "builtin.concat",
inputs: ["part-1", "part-2"]
}],
output: {input: "joined", extension: ".bin"}
}The app downloads inputs in parallel, runs pipeline steps and output processors in order, and saves the final file only after all steps succeed.
Pause and stop behavior depends on the current stage:
- Ordinary
http-filedownloads record progress for each Range segment and can resume after a pause. Separate-track and collection tasks also support pause and resume. - Pausing is no longer accepted once input processors, WASM, or media processing steps begin.
hlssupports cancellation only, not pausing.ffmpeg-hlslive recording uses Stop and Save to retain valid recorded content.
Available input executors:
http-file: ordinary HTTP/HTTPS files, with request headers, concurrent Range downloads, and the download proxy.capture-file: reads complete cached files from response capture, page writes or plugin saves without requesting a remote URL. Requirescapture-response-body.hls: parses HLS master and media playlists, supports relative URLs, highest/lowest bandwidth or a bandwidth limit for variant selection,EXT-X-MAP,BYTERANGE, and AES-128. It can download VOD content or only the segments already listed in the current playlist; it does not continuously follow a live stream. Pausing is unsupported.ffmpeg-hls: user-installed FFmpeg downloads or records network streams directly, with request headers, reconnection, and a maximum recording duration. Requiresmedia.ffmpeg.network. After recording stops, the app saves the valid recording.
Input and processing-step IDs must be valid and unique within the plan. Each processing step may reference declared inputs or steps preceding it. output.input selects the final result to save. capture-file uses a cache and cannot also provide a URL; other inputs must provide valid HTTP/HTTPS URLs.
Configure HLS through options:
{
id: "stream",
executor: "hls",
url: payload.m3u8,
extension: ".ts",
options: {
variant: "lowest",
maxBandwidth: 2500000,
requireEndList: true
}
}Permissions required by processing steps:
| Executor | Required permission |
|---|---|
builtin.concat | No additional permission |
builtin.media.mux | media.basic |
builtin.media.remux | media.basic |
builtin.media.extract_audio | media.basic |
plugin.ffmpeg | media.ffmpeg |
Media steps require compatible FFmpeg configured by the user. Plugins can declare a minimum version with Manifest requires.ffmpeg, such as ">=6.0".
These operations accept only host-managed inputs and outputs. plugin.ffmpeg takes an args array with {{input.0}} and {{output}} placeholders. The app uses FFmpeg detected from settings, without a shell, and does not accept arbitrary executable paths. Do not reference file paths that the app has not provided.
Plugin-provided WASM processors
Private encryption or transformation algorithms can be distributed as .wasm files with a plugin. JavaScript identifies resources and supplies arguments; the Go core handles restricted execution, streaming reads and writes, and rollback.
{
"permissions": {
"domains": ["api.example.com"],
"capabilities": ["observe-response", "emit-resource", "process-download"]
},
"processors": {
"decrypt": {
"runtime": "wasm",
"entry": "decrypt.wasm",
"apiVersion": 1
}
}
}Tracks and download plans select WASM modules through processor IDs declared in the Manifest, not arbitrary local file paths:
processors: [{
type: "plugin-wasm",
options: {
processor: "decrypt",
key: payload.key,
nonce: payload.nonce
}
}]Process local files
A process-file resource action lets users select a local file for a declared WASM processor:
{
"actions": {
"decrypt-local-file": {
"kind": "process-file",
"processor": "decrypt",
"inputExtensions": [".mp4"],
"outputExtension": ".mp4",
"locales": {
"zh": {"name": "解密本地视频", "description": "选择已下载的加密文件。"},
"en": {"name": "Decrypt Local Video", "description": "Select the downloaded encrypted file."}
}
}
}
}The resource carries the dynamic arguments needed for the action:
actions: [{
id: "decrypt-local-file",
data: {options: {key: payload.key, nonce: payload.nonce}}
}]The plugin does not receive file paths or arbitrary file access. The result is saved in the original directory with .decrypted added to the filename; the original file is preserved.
Resource operation references
Use kind: "operation" and operation to reference a Manifest operation, with input from the resource's action.data. See Plugin operations.
ABI v1
Modules must export linear memory and the following functions. All integers are WebAssembly i32:
int32_t rd_abi_version(void); // Must return 1
uint32_t rd_alloc(uint32_t size);
void rd_free(uint32_t pointer, uint32_t size); // Optional
int32_t rd_init(uint32_t options_pointer, uint32_t options_length);
int32_t rd_transform(
uint32_t pointer,
uint32_t input_length,
uint32_t capacity,
uint32_t offset_low,
uint32_t offset_high,
uint32_t final
);rd_initreceives UTF-8 JSON without the host's internal processor ownership fields. Return0on success.rd_allocmust return a valid linear memory address with space for the requested length. The app validates the address range.- Input chunks are at most 256 KiB, and the module writes back into the same memory region.
rd_transformreturns an output byte count in0..capacity; a negative value means processing failed. capacityis currently 320 KiB.offset_low/highrepresent the current input's unsigned 64-bit byte offset.final=1marks the last call. If the file size is an exact multiple of the chunk size, the app makes one additionalrd_transformcall with input length 0 andfinalset to 1.rd_freeis optional. If provided, it must release memory returned byrd_alloc. Modules must not retain addresses that the host has already freed.
WASM modules cannot import WASI or app-provided functions, so they cannot access networks, filesystems, environment variables, clocks, or system commands. Each module is limited to 8 MiB, linear memory to 64 MiB, and options to 64 KiB. Individual calls and the whole processing operation have time limits. If processing fails, the app deletes temporary output and preserves the original file. A complete example lives in examples/plugins/wasm-xor/.
Declarative plugins
The declarative runtime is suitable for JSON APIs where one response directly produces a single-track resource:
runtime: declarative
permissions:
domains: [api.example.com]
capabilities: [observe-response, read-response-body, emit-resource]
match:
- stage: response
host: api.example.com
path: /videos
extractors:
- stage: response
format: json
root: $.data.items[*]
resource:
url: {path: $.playUrl}
title: {path: $.title}
kind: {value: media.video}
role: {value: video}
executor: {value: http-file}
preview: {value: video}
contentType: {value: video/mp4}
extension: {value: .mp4}The supported JSON Path syntax includes $.a.b, numeric array indexes, and a trailing [*]. Use the JavaScript runtime for combining multiple requests, correlation, or custom download plans.
Declarative fields:
| Field | Description |
|---|---|
stage | request or response, usually response |
format | Currently only json |
root | Selects an object or an array of objects; defaults to the entire JSON |
resource.url | Required download URL selector |
resource.title / coverUrl | Optional title and cover URL |
resource.kind | Resource kind; should match an ID declared in resourceKinds |
resource.role | Track role; inferred from the last part of kind if omitted |
resource.executor | Defaults to http-file |
resource.contentType / extension / size | File type, extension, and byte count |
resource.preview | image, audio, video, pdf, or text |
resource.metadata | Custom metadata selectors; namespace site-specific fields |
Each selector uses {path: "$.field"} to read data or {value: "constant"} for a fixed value. Entries without a URL are skipped, and numeric size values are converted to integers. The declarative runtime does not execute custom code, retain state across responses, or generate multi-input download plans.
Validation and offline replay
Validate plugins without starting the proxy:
go run main.go plugin lint ./plugins/com.example.my-plugin
go run main.go plugin replay ./examples/plugins/javascript-basic ./examples/plugins/javascript-basic/fixtures/video.jsonA fixture contains a sanitized observation and expected results:
{
"observation": {
"stage": "response",
"request": {
"method": "GET",
"url": "https://api.example.com/api/videos/42",
"host": "api.example.com",
"path": "/api/videos/42",
"headers": {}
},
"response": {
"statusCode": 200,
"headers": {"Content-Type": ["application/json"]},
"contentType": "application/json",
"body": "{\"url\":\"https://cdn.example.com/42.mp4\"}"
}
},
"expected": {
"resourceCount": 1,
"resourceUrls": ["https://cdn.example.com/42.mp4"]
}
}A fixture can contain one observation or use observations to replay several requests in order, such as page API, video, audio, and scrolling requests. Resources with the same groupKey are merged using the app's actual rules.
Expected results support resourceCount, resourceUrls, processorTypes, decision, and patchBodyContains. Templates live in examples/plugins/. See Plugin SDK v1 for JSON Schema and TypeScript declarations.
Keep only fields needed to match requests and generate resources. Retain real domains, but replace private paths, account details, and resource IDs with stable example values. Remove or replace cookies, Authorization headers, device identifiers, and temporary signatures. Assert at least resource count and URLs; processor plugins should also assert processorTypes.
Debugging and common errors
Run plugin lint and plugin replay first, then reload the plugin in the app and trigger new page requests. Check the plugin card and logs for errors:
| Symptom | Common causes |
|---|---|
| Plugin fails to load | Invalid ID, version, API version, entry path, or permission dependency |
| Hook never runs | Stage, domain, path, method, or Content-Type does not match |
Empty response.body | Missing read permission, readBody: false, or a different rule matched |
| JSON parsing fails | Truncated body, non-JSON response, or a login/anti-abuse page |
| Resource appears but cannot download | Missing required tracks, invalid URL, undeclared processor, or incomplete capabilities |
| Plugin temporarily stops processing | Consecutive errors or timeouts triggered suspension; fix the issue and reload |
| Page script is not injected | No TLS interception, compressed response, restrictive CSP, or no suitable injection point |
| Fixture passes but live traffic fails | Missing fixture branches, changed site data, expired credentials, or a different request order |
Avoid processing oversized objects in loops or logging complete responses and sensitive URLs. Prepare sanitized fixtures for recurring issues. See Plugin operations for operation time limits.
Pre-release checks
- ID, version, and permissions meet the requirements; names, descriptions, resource kinds, and settings have Chinese and English text.
- Logs, fixtures, README, and example settings are sanitized.
plugin lint,plugin replay, andplugin packsucceed, with fixtures covering major branches and multi-request correlation.- The package includes JavaScript, WASM, page scripts, and other runtime files.
- Installation and basic operations are verified using the final ZIP in the current stable app.
See Extension Store for listing requirements, repository configuration, and publishing steps.
Maintaining bundled plugins (project maintainers only)
These commands maintain plugin snapshots shipped with the app. Independent plugin authors do not need them.
From the res-downloader source root, run go run main.go plugin sync-bundled <plugin-directory> to validate the source plugin and replace the old snapshot with the same ID under internal/plugin/bundled/, using the source directory's name.
Use go run main.go plugin lint-bundled <directory> to check a preinstalled snapshot. It accepts only IDs present in the application image used by the command, with directory contents exactly matching that embedded version. Ordinary plugin development uses lint; these commands do not grant official status.
