Skip to content

Plugin Development

60plus edited this page Jul 11, 2026 · 13 revisions

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.

Contents

How plugins are loaded

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 its plugin.json manifest.

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 gd3 entry-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 a pip install package.

Two safety checks matter when you author a plugin:

  • The manifest entry value must be a plain filename. During the startup directory scan, any entry containing .., / 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 your entry as a plain filename regardless.
  • The plugin id must not contain /, \ or .., because it becomes the on-disk directory name.

Plugin types and hooks

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, library and widget specs (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 are metadata, lifecycle (startup/shutdown only), and theme (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.

Metadata provider hooks

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.

Download provider hooks

Reserved - not yet wired. The download plugin 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.

Library source hooks

Reserved - not yet wired. The library plugin 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.

Lifecycle hooks

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.

Frontend provider hooks

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

Widget hooks

Reserved - not yet wired. The widget plugin 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.

The plugin.json manifest

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_version gate. When set, installation compares the required version against the running server. If the server is older than min_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, so 1.0.15 and v1.0 both work); an unparseable value simply skips the check rather than blocking the install. Bump min_gd_version whenever your plugin starts relying on a newer hook or __GD__ capability, so it will not be installed onto a server that cannot support it.

The Python Plugin class

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 in lifecycle_on_startup. A plugin loaded at runtime (enabled or freshly installed from the browser) is instantiated after the app has already started, so its lifecycle_on_startup hook will not fire for that boot.

Reading plugin config

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.

Packaging and installing

Settings → Plugins: installed plugins with enable, configure, and remove

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.json must be at the ZIP root. The installer looks for plugin.json at 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 runs pip install --target vendor/ -r requirements.txt and adds that vendor/ directory to sys.path at load time. Do not vendor into arbitrary locations; rely on requirements.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 root

The frontend GD bridge

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

Theme 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 .vue layouts 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.

Plugin i18n

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.

Next steps

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

Clone this wiki locally