Skip to content

Plugin Development

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

Plugin Development

GamesDownloader has a 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
metadata Provide game/collection metadata, covers, heroes, logos, ratings MetadataProviderSpec
download Provide a download source for games DownloadProviderSpec
library Scan a path and report discovered games/ROMs LibrarySourceSpec
lifecycle React to app and library lifecycle events LifecycleSpec
theme Inject CSS/JS or a full Vue theme FrontendProviderSpec
widget Contribute dashboard widget cards WidgetSpec

Every hook spec above is callable. GamesDownloader also sends its own built-in "recently added" notification (Discord embed + optional email) when a game or ROM becomes ready - see Email and Notifications. lifecycle_on_game_added fires alongside that built-in delivery, so a plugin can react or route the event elsewhere on top of it.

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 Fallback single cover URL - called when a metadata fetch produced no cover. For a full cover picker use metadata_get_covers.
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

Registered providers are discoverable at GET /api/plugins/download/providers (pass ?game_id= to report can_handle), started via POST /api/plugins/download/providers/{id}/start, and polled via GET /api/plugins/download/providers/{id}/status/{task_id}.

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

Registered sources are listed at GET /api/plugins/library/sources and scanned via POST /api/plugins/library/sources/{id}/scan. (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

game is a plain dict ({id, title, source, slug, gog_game_id}); fetch more via the API using id. Hooks are fired best-effort - an exception is logged and never breaks the library write or the launch.

Hook Called when
lifecycle_on_startup() The application starts. (Not fired for a hot-loaded plugin - run setup from __init__.)
lifecycle_on_shutdown() The application shuts down.
lifecycle_on_game_added(game) A game was added to the library (custom create / GOG publish / torrent finish).
lifecycle_on_download_complete(game, path) A download finished (path = destination folder).
lifecycle_on_play_start(game) A game/ROM was launched in the player.
lifecycle_on_play_end(game, seconds) A play session ended (seconds = elapsed play time). ROM launches call POST /api/roms/{id}/play/start|end.

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 Custom plugin pages {path, label, icon}. Each becomes /x/<path> with a user-menu entry; render the page by calling __GD__.registerRoute({path, mount}) from frontend_get_js. Aggregated at GET /api/plugins/frontend/routes.

Widget hooks

Cards render on the built-in Dashboard page (/dashboard) and are served from GET /api/plugins/dashboard/cards. Each card is {id, title, value, subtitle, icon, link} (icon = an mdi name such as "mdi-controller", link = an in-app path such as "/x/my-page").

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(), isHidden(id), order(ids) and isOptionOn(section, opt) for theme-declared home-page sections that users can toggle and reorder under Settings → Libraries - unless the theme claims the block through managedSettings, in which case Settings drops those controls and the theme's own editor is the only place they live. Register from a layout (not a page view) so the list survives navigation. A section may set orderable: false to pin its position (Settings shows the checkbox but no reorder arrows), and a per-section options entry may declare a default that is the source of truth for isOptionOn until the user toggles it. Since 1.0.26 a theme that ships its own layout editor can also write the state back: setOrder(ids), setHidden(id, hidden), toggle(id), setOption(section, opt, on) and reset(). Writes are stored per-user, per-theme and sync to the server like any other theme setting. reset() drops this user's whole layout for the active theme - order, hidden sections and per-section options - and falls back to what register() declared, leaving the theme's other settings such as skin and cover size untouched. The built-in "Continue playing" and "Recently played" rails are registered this way. See the full example in the template's HOOKS.md.
managedSettings register(keys) claims the settings blocks your theme edits itself and returns an unclaim function; isManaged(key) reports whether the active theme handles a block. Core Settings stops drawing its own controls for a claimed block, so the same state is never offered in two places. Three keys are honoured, all of them blocks of Settings → Libraries: libraryVisibility, recentLibraries and homeSections. The registry is module-level rather than per theme, and it is not cleared when the theme changes, so claim on mount and call the returned unclaim function on unmount. Requires min_gd_version 1.0.26.
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 files into one archive per group - each OS platform plus the extras and DLC folders (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. Since 1.0.23 it also exposes gogSync(opts) / gogSyncStatus() / gogClearMetadata() (drive the GOG library toolbar: start a sync and poll it, or clear the whole GOG library's scraped metadata) and clearGameMetadata(kind, id) (clear one game's metadata so it can be re-scraped; kind is games, gog or rom) - all admin-only.
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.

Claim only what you replace. Vapor claims ["homeSections"] and deliberately leaves libraryVisibility and recentLibraries unclaimed. Those two are library lists rather than page sections, and Vapor's own editor offers no replacement for them, so claiming them would take the controls out of Settings with nothing to put in their place. Claim a key only where your theme genuinely covers the same ground.

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 and working examples, read the complete hook and window.__GD__ reference (every backend hook and every frontend API member, with signatures and return shapes), and keep the security model in mind while you build.

Next: Plugin Trust Model and Themes

Clone this wiki locally