-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development
GamesDownloader ships a first-class plugin system so you can add metadata sources, download providers, library scanners, dashboard widgets, lifecycle hooks and complete Vue themes without forking the core. Plugins are Python packages loaded through pluggy on the backend, and they can optionally ship a frontend layer that talks to the running app through a window.__GD__ bridge.
This page is the developer reference. It covers the plugin concept, the available hook types, the plugin.json manifest, packaging and installation, reading per-plugin config, and the frontend bridge. The fastest way to start is to clone the starter templates and working examples in the gd3-plugin-template repository.
Warning: read the trust model first. A plugin is arbitrary Python that runs inside the server process with full server privileges, plus arbitrary JavaScript that runs in every user's browser. There is no sandbox. Only install plugins you have read and trust, and treat installation as equivalent to running code on the host. See Plugin Trust Model before you write or install anything.
- How plugins are loaded
- Plugin types and hooks
- The plugin.json manifest
- The Python Plugin class
- Reading plugin config
- Packaging and installing
- The frontend GD bridge
- Theme plugins
- Plugin i18n
- Next steps
The plugin manager discovers plugins from two places at startup:
-
Built-in plugins shipped inside the backend (
plugins/builtin/). -
External plugins in the installed-plugins directory (
data/plugins, one subdirectory per plugin). Each directory is loaded according to itsplugin.jsonmanifest.
For every external plugin directory, the manager reads plugin.json, resolves the entry file, imports it, and registers the exported Plugin class instance with pluggy. A plugin that has been disabled in the UI is skipped at load time. Plugins can also be loaded and unloaded at runtime when you enable, disable or install them from the browser, without a full restart (theme plugins are the exception, since their .vue files are compiled at container startup).
Setuptools entry points are deliberately disabled. GamesDownloader does not load plugins from installed pip packages via the
gd3entry-point group. That path is a supply-chain risk, so the loader leaves it switched off on purpose. Plugins are only ever installed as a reviewed ZIP, never as apip installpackage.
Two safety checks matter when you author a plugin:
- The manifest
entryvalue must be a plain filename. During the startup directory scan, anyentrycontaining..,/or\is rejected, and the resolved entry path must stay inside the plugin's own directory. Note that this entry check runs only on the startup scan; the runtime install/enable path (used when a plugin is installed or enabled from the store without a restart) does not repeat it, and relies instead on the install-time Zip Slip protection and the plugin-id check below. Build yourentryas a plain filename regardless. - The plugin
idmust not contain/,\or.., because it becomes the on-disk directory name.
The type field in your manifest declares what kind of plugin you are shipping. The allowed values are:
type |
Purpose | Hook spec | Status |
|---|---|---|---|
metadata |
Provide game/collection metadata, covers, heroes, logos, ratings | MetadataProviderSpec |
Wired |
download |
Provide a download source for games | DownloadProviderSpec |
Reserved - not yet wired |
library |
Scan a path and report discovered games/ROMs | LibrarySourceSpec |
Reserved - not yet wired |
lifecycle |
React to app and library lifecycle events | LifecycleSpec |
Wired (startup/shutdown only) |
theme |
Inject CSS/JS or a full Vue theme | FrontendProviderSpec |
Wired |
widget |
Contribute dashboard widget cards | WidgetSpec |
Reserved - not yet wired |
Some hook specs are declared but not yet wired. The
download,libraryandwidgetspecs (plus a few individual hooks flagged below) exist in the hook contract, but nothing in the core currently calls them, so implementing them has no effect yet. They are reserved for a future release and documented here so plugin authors know what is coming. The specs that are fully usable today aremetadata,lifecycle(startup/shutdown only), andtheme(CSS/JS/theme). Build against those; treat the rest as forward-looking.
A single plugin may implement hooks from more than one spec; the type is primarily a categorisation for the store and management UI. Implement a hook by decorating a method on your Plugin class with the hookimpl marker. Only implement the hooks you need; every hook is optional.
| Hook | Returns | Notes |
|---|---|---|
metadata_provider_id() |
str |
Unique id, e.g. "igdb". |
metadata_provider_name() |
str |
Display name. |
metadata_provider_ratings() |
bool |
Whether this provider returns numeric 0-10 ratings (rendered/edited as scores). Badge-style providers (tiers, statuses) return False. A provider that omits this hook is assumed to return ratings. |
metadata_search_game(query) |
list[dict] |
Search games by title; return match dicts. |
metadata_get_game(provider_game_id) |
dict | None |
Full metadata for one game. |
metadata_get_cover_url(provider_game_id) |
str | None |
Reserved - not yet wired. Not called by the core; use metadata_get_covers for cover art. |
metadata_get_covers(query) |
list[dict] |
Cover image candidates (see shape below). |
metadata_get_heroes(query) |
list[dict] |
Hero/background/fanart candidates. |
metadata_get_logos(query) |
list[dict] |
Logo/clearlogo candidates. |
metadata_search_collection(query) |
list[dict] |
Search franchises/series/collections by name. |
metadata_get_collection(provider_collection_id) |
dict | None |
Full collection metadata; rating is on a 0-5 scale. |
The artwork hooks (metadata_get_covers, metadata_get_heroes, metadata_get_logos) all return a list of dicts of the same shape:
{
"url": "https://example/full.png",
"thumb": "https://example/thumb.png",
"type": "static",
"label": "Game Title - Box Art",
"author": "optional credit"
}type is "static" or "animated", thumb may be the same as url, and author is optional. The same artwork hooks are reused for collection artwork, keyed by the collection name.
Metadata plugins work across every library. GOG, Games Library and the ROM Library all call these hooks for covers, heroes, logos, screenshots, descriptions and ratings.
Reserved - not yet wired. The
downloadplugin type and these hooks are declared for a future release; the core does not currently call any of them, so a download provider plugin does nothing yet.
| Hook | Returns | Notes |
|---|---|---|
download_provider_id() |
str |
Unique id, e.g. "gog", "torrent". |
download_provider_name() |
str |
Display name. |
download_can_handle(game_id) |
bool |
Whether this provider can download the game. |
download_start(game_id, destination) |
dict |
Start a download; return a status dict with at least task_id. |
download_get_status(task_id) |
dict |
Progress dict with progress, status, etc. |
Reserved - not yet wired. The
libraryplugin type and these hooks are declared for a future release; the core does not currently call any of them, so a library source plugin does nothing yet. (The built-in libraries and ROM scanning are separate core features, unrelated to this hook.)
| Hook | Returns | Notes |
|---|---|---|
library_source_id() |
str |
Unique id. |
library_source_name() |
str |
Display name. |
library_scan(path) |
list[dict] |
Scan a path and return discovered games/ROMs. |
| Hook | Called when |
|---|---|
lifecycle_on_startup() |
The application starts. |
lifecycle_on_shutdown() |
The application shuts down. |
lifecycle_on_game_added(game) |
Reserved - not yet wired. Intended for when a new game is added to the library; not currently emitted. |
lifecycle_on_download_complete(game, path) |
Reserved - not yet wired. Intended for when a download finishes; not currently emitted. |
| Hook | Returns | Notes |
|---|---|---|
frontend_get_theme() |
dict | None |
Theme definition (colours, fonts, etc.). |
frontend_get_css() |
str | None |
CSS string injected into the frontend. |
frontend_get_js() |
str | None |
JavaScript executed in the frontend on load. |
frontend_get_routes() |
list[dict] | None |
Reserved - not yet wired. Intended for custom plugin pages {path, label, icon}; the frontend does not currently consume it (the plugin frontend endpoints serve only CSS, themes, JS and i18n). |
Reserved - not yet wired. The
widgetplugin type and this hook are declared for a future release. There is no dashboard host that renders widget cards yet, so implementing this hook has no effect.
| Hook | Returns | Notes |
|---|---|---|
widget_get_cards() |
list[dict] | None |
Dashboard widget card definitions. |
Every plugin ships a plugin.json manifest at its root. These fields are required, and installation is refused if any is missing:
| Field | Meaning |
|---|---|
id |
Unique identifier and on-disk directory name. Must not contain /, \ or ... |
name |
Human-readable display name. |
version |
Plugin version string. |
author |
Author name. |
type |
One of metadata, download, library, theme, widget, lifecycle. |
entry |
Python entry file. Conventionally plugin.py; must be a plain filename with no path separators. |
Optional fields include:
| Field | Meaning |
|---|---|
min_gd_version |
Minimum GamesDownloader version required to install this plugin. |
Example:
{
"id": "my-scraper",
"name": "My Scraper",
"version": "1.0.0",
"author": "you",
"type": "metadata",
"entry": "plugin.py",
"min_gd_version": "1.0.15"
}The
min_gd_versiongate. When set, installation compares the required version against the running server. If the server is older thanmin_gd_version, the install is refused with a clear message telling the admin to update GamesDownloader first. Version strings are parsed leniently (the first up-to-three numeric segments, so1.0.15andv1.0both work); an unparseable value simply skips the check rather than blocking the install. Bumpmin_gd_versionwhenever your plugin starts relying on a newer hook or__GD__capability, so it will not be installed onto a server that cannot support it.
Your entry file must export a class named exactly Plugin. The loader imports the module and registers module.Plugin(); a module without a Plugin class is skipped (or raises an error on an explicit runtime load). Decorate the methods you implement with hookimpl, imported from plugins.hookspecs.
from plugins.hookspecs import hookimpl
from plugins.manager import get_plugin_config
class Plugin:
@hookimpl
def metadata_provider_id(self):
return "my-scraper"
@hookimpl
def metadata_provider_name(self):
return "My Scraper"
@hookimpl
def metadata_provider_ratings(self):
# This provider returns numeric 0-10 ratings.
return True
@hookimpl
def metadata_search_game(self, query):
cfg = get_plugin_config("my-scraper")
api_key = cfg.get("api_key", "")
# ... call your API, then return match dicts ...
return [{"provider_game_id": "123", "title": query}]Do heavy one-time setup in
__init__, not inlifecycle_on_startup. A plugin loaded at runtime (enabled or freshly installed from the browser) is instantiated after the app has already started, so itslifecycle_on_startuphook will not fire for that boot.
Per-plugin settings entered in the management UI are stored in the database and read back with the synchronous helper get_plugin_config(plugin_id):
from plugins.manager import get_plugin_config
cfg = get_plugin_config("my-scraper")
api_key = cfg.get("api_key", "")It returns the parsed config dict for your plugin (or an empty dict if nothing is stored yet), reading the plugin_configs table over the synchronous database engine so it is safe to call from any hook, including during startup. Config forms are rendered from your plugin's declared fields as boolean, string, number and select inputs under Settings → Plugins.

Settings → Plugins: installed plugins with enable, configure, and remove
Plugins are installed as a ZIP through Settings → Plugins (upload a file, or install from a configured store source).
Packaging rules:
-
plugin.jsonmust be at the ZIP root. The installer looks forplugin.jsonat the top level first; as a convenience it also accepts a ZIP that contains a single top-level folder holding the plugin. Keeping the manifest at the root is the reliable option. -
Dependencies are handled for you. If your plugin ships a
requirements.txt, the installer runspip install --target vendor/ -r requirements.txtand adds thatvendor/directory tosys.pathat load time. Do not vendor into arbitrary locations; rely onrequirements.txt. - The archive is extracted with Zip Slip protection (absolute paths and
..are rejected). Backslash separators from Windows-built archives are normalised to forward slashes, but you should build ZIPs with forward slashes to be safe.
A minimal build with Python's zipfile, writing plugin.json at the archive root:
cd my-scraper
zip -r ../my-scraper.zip . -x '.*'
unzip -l ../my-scraper.zip # verify plugin.json is at the rootA plugin's frontend code (theme layouts, injected JS, metadata tabs, detail rows) cannot import from the app's internals. Everything it needs is exposed on a single global object, window.__GD__. The bridge includes:
| Member | What it gives you |
|---|---|
Vue, VueRouter
|
The shared Vue and Vue Router runtimes, so your components use the same instances as the app. |
api |
The authenticated Axios client for calling the backend API. |
stores |
Pinia stores: auth, socket, theme, libraries, collections. |
events |
Narrow, whitelisted subscription bridge for server progress events: events.on("upload:url_progress", cb) returns an unsubscribe function. |
utils |
Shared helpers, including sanitizeHtml(html) (the same sanitiser used for descriptions) and buildLanguageList(dict). |
registerMetadataTab(tab) |
Add a custom tab inside the core metadata editor panels (supports an onSave contribution). |
registerDetailRow(row) / resolveDetailRows(...)
|
Contribute theme-native rows to game/ROM detail pages; the consumer side lets a custom theme render those rows itself. |
homeSections |
register([...]), list() and isHidden(id) for theme-declared home-page sections that users can toggle under Settings → Libraries. |
recentLibraries, collections
|
Data-driven access to the per-theme "recently added" feed and to admin-curated collections. |
library |
Unified, library-aware add-content actions (createGame, uploadFile, uploadFromUrl, addTorrent, scan, addByUpload). A theme keeps its own dialogs but calls these instead of raw api.post, so create/upload/torrent/scan all target the current library (folder + membership) with no per-theme logic. Requires min_gd_version 1.0.17. Since 1.0.19 it also exposes package(gameId) and packable(gameId): bundle any library game's loose per-platform files into one archive each (GOG, custom, or an admin custom library), so a theme can offer a "Package" button on any game. package runs server-side and reports progress on the download:packaging socket event; packable returns the platforms worth bundling so the button can be shown or hidden. |
ui |
Imperative access to shared editors and dialogs: openMetadataEditor, openRomMetadataEditor, openCollectionEditor, styled confirm()/alert(), and openAbout(). |
icons |
The built-in library icon set for rendering library glyphs natively. |
i18n |
The i18n instance, including i18n.merge(dict) for adding translations. |
notifications |
notifications.add({...}), dismiss(id), remove(id) to drive the badge on the user avatar. |
This table covers the members you will reach for most often. window.__GD__ also exposes the theme-registration APIs (registerTheme, registerPluginLayout, registerPluginCouchMode), shared Vue composables, and a few smaller helpers used by full themes (see Theme plugins). The event bridge in particular is a whitelist and will warn if you subscribe to an event that is not exposed to plugins.
A theme plugin can go far beyond a colour skin. It may ship complete Vue single-file component (.vue) layouts (navbar, home page, detail pages and so on), which are compiled by Vite when the container starts. At runtime those components reach the app through the window.__GD__ bridge described above, registering themselves with registerTheme, registerPluginLayout and related APIs, and reading stores and data through the same bridge.
Because
.vuelayouts are compiled at container startup, installing or updating a theme plugin needs a container restart to take effect. The Plugin Store surfaces a restart button for exactly this case. See Themes for the user-facing side.
The gd3-plugin-template repository includes a full example theme you can copy and adapt.
Plugins ship their own translations in an i18n.json file. At runtime the app fetches installed plugins' translations and merges them into the active dictionary (via window.__GD__.i18n.merge()), so a plugin can reference its own i18n keys in labels and UI without hardcoding any strings into the main application. Provide keys per language in i18n.json, and reference them from your components and from things like home-section labels.
Clone gd3-plugin-template for starter templates, working examples and a hook reference, and keep the security model in mind while you build.
Next: Plugin Trust Model and Themes
Getting Started
Configuration
Features
- Dashboard
- Library
- Collections
- Game Requests
- GOG Integration
- ROMs & Emulation
- Downloads & Torrents
- Users & Permissions
- Themes
Plugin Development
Reference