Templates, examples, and documentation for building plugins for GamesDownloader V3.
Looking to install plugins? Go to gd3-plugin-store for ready-to-install ZIPs, or add the store URL in Settings > Plugin Store.
| Type | Description | Hooks | Example |
|---|---|---|---|
| theme | Custom layouts, Vue components, CSS, JS, couch mode | FrontendProviderSpec |
NEON HORIZON |
| metadata | Scrape game info from external sources | MetadataProviderSpec |
PPE.pl Scraper |
| lifecycle | Hook into app events (startup, game added, download done) | LifecycleSpec |
Description Translator |
| download | Add new download sources | DownloadProviderSpec |
- |
| library | Scan game libraries from new sources | LibrarySourceSpec |
- |
| widget | Add dashboard cards | WidgetSpec |
- |
- Copy a starter template from
templates/matching your plugin type - Edit
plugin.jsonwith your plugin info - Implement hooks in
plugin.py - ZIP the folder and install via Settings > Plugins
my-plugin/
plugin.json # manifest (required)
plugin.py # Python class with @hookimpl methods (required)
logo.png # icon shown in Settings (optional)
requirements.txt # pip dependencies, installed automatically (optional)
i18n.json # plugin translations (optional, theme/lifecycle plugins)
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "What this plugin does",
"type": "metadata",
"entry": "plugin.py",
"requires": ["httpx", "beautifulsoup4"],
"min_gd_version": "1.0.15",
"config_schema": {
"api_key": {
"type": "string",
"default": "",
"label": "API Key"
},
"enabled": {
"type": "boolean",
"default": true,
"label": "Enable"
}
}
}| Field | Required | Meaning |
|---|---|---|
id |
yes | Unique plugin id (kebab-case). Also the install folder name and the plugin_id you pass to get_plugin_config(). |
name |
yes | Human-readable name shown in Settings and the store. |
version |
yes | Semver string; drives update detection and the built ZIP filename (<id>-v<version>.zip). |
type |
yes | Which hook family this plugin implements: metadata, download, library, lifecycle, theme, or widget. A plugin may implement more than one family; type is the primary one used for grouping. |
entry |
yes (Python) | The Python module holding your Plugin class (usually plugin.py). A theme with no Python still sets it (the templates use plugin.py). |
min_gd_version |
yes | Lowest supported GamesDownloader version (see below). |
author |
no | Your name/handle, shown in the store. |
description |
no | One-line summary shown in Settings and the store. |
requires |
no | Extra pip packages your plugin.py imports, installed on load, e.g. ["httpx", "beautifulsoup4"]. Leave [] if none. |
has_logo |
no | true when you ship a logo.png or logo.svg next to plugin.json; it is used as the plugin icon. |
config_schema |
no | User-editable settings form (see below). Theme appearance settings go in frontend_get_theme().settings[] instead, not here. |
Config schema fields render as a settings form in Settings > Plugins. Supported type values: string, number, boolean, select. A select field MUST include an options array:
"config_schema": {
"search_engine": {
"type": "select",
"options": ["bing", "duckduckgo"],
"default": "bing",
"label": "Primary search engine"
}
}Reading your config. Whatever the user saves is stored server-side and read synchronously from your plugin.py (no async, works from any hook):
from plugins.manager import get_plugin_config
cfg = get_plugin_config("my-plugin") # the "id" from plugin.json
engine = cfg.get("search_engine", "bing")
enabled = cfg.get("enabled", True)min_gd_version is the lowest GamesDownloader version your plugin works on - set it to the version that introduced the newest API you call. Since GamesDownloader v1.0.15 it is ENFORCED: installing a plugin on an older server is refused (upload, URL and store installs alike), and the Plugin Store greys the Install button out with a "Requires GD x.y.z+" badge. The store also shows the running server version ("GD Version") next to the Available Plugins header.
config_schema vs theme settings: Theme plugins use
frontend_get_theme().settings[]for appearance settings (blur, particles, colors) - these are rendered in Settings > Appearance > Theme Settings. Theconfig_schemain plugin.json is for plugin configuration (rendered in Settings > Plugins). Theme plugins typically have empty"config_schema": {}.
Your plugin.py must define a Plugin class using @hookimpl decorators:
from plugins.hookspecs import hookimpl
class Plugin:
@hookimpl
def metadata_provider_name(self) -> str:
return "My Source"
@hookimpl
def metadata_provider_id(self) -> str:
return "my-source"
from plugins.hookspecs import hookimplresolves against the GamesDownloader runtime (thepluginspackage lives in the server, not in your plugin). So it imports only when your plugin runs inside GD - it will not resolve if you runplugin.pystandalone, and your editor may flag it as unresolved. That is expected; do not vendor a copy ofhookspecs.
See docs/HOOKS.md for the full hook reference with all specs and return types.
Source: examples/ppe-metadata/
A metadata scraper that finds Polish game descriptions, ratings, genres, and screenshots from PPE.pl.
How it works:
metadata_search_game(query)searches PPE.pl via Bing/DuckDuckGo, returns matching pagesmetadata_get_game(url)scrapes the page with BeautifulSoup, extracts structured datametadata_get_cover_url(url)extracts the game cover image URL- Fuzzy title matching with configurable threshold (default 65%)
Hooks implemented: MetadataProviderSpec
metadata_provider_name()/metadata_provider_id()- identitymetadata_search_game(query)- search PPE.pl, return[{provider_id, provider_game_id, name, snippet}]metadata_get_game(provider_game_id)- scrape full metadata: title, description, rating (0-100), genre, release_date, developer, screenshots, source_urlmetadata_get_cover_url(provider_game_id)- cover image URL
Config: search engine (bing/duckduckgo), minimum match score
Dependencies: beautifulsoup4, httpx
Files:
ppe-metadata/
plugin.json # manifest, type: metadata
plugin.py # 450 lines - search + scrape + fuzzy match
logo.png # plugin icon
requirements.txt # beautifulsoup4, httpx
Source: examples/gd3-translator/
Translates game descriptions between 26 languages using Google Translate (via translate-shell).
How it works:
- GD backend has a built-in
/api/plugins/translateendpoint that callstranslate_text()from this plugin - Frontend shows a translate button on game detail pages
- Plugin splits long text into ~450-char chunks at
\nparagraph boundaries - Each chunk is translated via
translate-shellpackage in a separate thread - Results are joined back together and returned
Hooks implemented: LifecycleSpec
lifecycle_on_startup()- logs that the plugin is loaded (the translate endpoint is provided by GD's plugins_router, not registered by the plugin itself)
Key patterns:
- Thread-safe translation: uses
concurrent.futures.ThreadPoolExecutorbecausetranslate-shellinternally callsasyncio.run()which conflicts with FastAPI's event loop - Text chunking: splits at
\nboundaries, max ~450 chars per chunk - Language detection:
from_lang: "auto"uses Google's auto-detect - Return format:
{"ok": bool, "text": str, "from_lang": str, "to_lang": str, "error": str}
Config (Settings > Plugins > Translator):
from_lang- source language (select, 26 options including "auto")to_lang- target language (select, 26 options)- Uses
config_schemawithselecttype - rendered as dropdown in plugin settings
Dependencies: translate-shell>=0.0.59
Files:
gd3-translator/
plugin.json # manifest, type: lifecycle, config_schema with select fields
plugin.py # 140 lines - translate function + chunking logic
logo.png # plugin icon
requirements.txt # translate-shell
Source: examples/neon-horizon/
A complete cyberpunk theme with custom Vue layouts, Colorful Pop couch mode, 8 color skins, and gamepad support. This is the most complex plugin type - study it to understand the full capabilities.
Key features (v1.2.5):
- Netflix-style home page with hero banner and recently added rows
- Steam Big Picture library views (16:9 landscape cards)
- Colorful Pop couch mode with platform carousel, video playback in TV cutouts, favorites and recently played
- Per-game platform color in favorites/recent (reads platform color from platforms.json)
- Locale-aware platform descriptions (reads UI language from localStorage, falls back to English)
- Notification badge support (polls
__GD__.notifications.storefor update badges on avatar) - Full i18n via i18n.json (58 keys EN+PL)
- 8 color skins (4 solid + 4 dual-gradient)
- Configurable settings: particle count, glass blur, glass opacity, scanline overlay
Theme plugins provide three hook implementations:
from plugins.hookspecs import hookimpl
from pathlib import Path
class Plugin:
@hookimpl
def frontend_get_theme(self):
return {
"id": "neon-horizon",
"name": "NEON HORIZON",
"layout": "neon-horizon",
"skins": [
{"id": "nh-cyber", "name": "Cyan Flux", "preview": "#00d4ff"},
{"id": "nh-violet", "name": "Violet Surge", "preview": "#8b5cf6"},
],
"defaultSkin": "nh-cyber",
"cssFile": "neon-horizon",
# Google Fonts URL - loaded as a <link> in <head>, makes the font
# available for use in your CSS (e.g. font-family: 'Orbitron')
"font": "https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap",
"settings": [
{
"key": "particleCount",
"label": "nh.setting_particles",
"hint": "nh.setting_particles_hint",
"type": "select",
"default": "6",
"options": ["0", "3", "6", "12"],
# optionLabels can be i18n keys - the Settings UI translates them
"optionLabels": ["nh.opt_none", "nh.opt_few", "nh.opt_normal", "nh.opt_many"],
"cssVar": "--nh-particle-count",
},
],
}
@hookimpl
def frontend_get_css(self):
return Path(__file__).parent.joinpath("neon-horizon.css").read_text(encoding="utf-8")
@hookimpl
def frontend_get_js(self):
return Path(__file__).parent.joinpath("neon-horizon.js").read_text(encoding="utf-8")Theme plugins with .vue files are compiled on container startup by Vite:
- Plugin has
.vuefiles +plugin.jsonwith"type": "theme" - Container entrypoint runs
compile-theme-plugins.mjs - Vite compiles to IIFE bundle:
layout.js+layout.css - Frontend loads and registers components via
window.__GD__
After installing/updating a theme plugin, restart the container (Plugin Store has a "Restart Now" button).
Vue file names must follow a specific naming convention based on the layout ID in your theme definition:
- Layout ID
"my-theme"maps toMyThemeLayout.vue(main layout shell) - Layout ID
"my-theme"maps toMyThemeCouch.vue(couch mode component)
The conversion is kebab-case to PascalCase plus the suffix (Layout or Couch). Examples:
| Layout ID | Layout File | Couch File |
|---|---|---|
neon-horizon |
NeonHorizonLayout.vue |
NeonHorizonCouch.vue |
retro-wave |
RetroWaveLayout.vue |
RetroWaveCouch.vue |
minimal |
MinimalLayout.vue |
MinimalCouch.vue |
The compiler scans for *Layout.vue and *Couch.vue files automatically. You do not need to register them manually.
Compiled Vue plugins auto-register their layout and couch components - you do not need to call registerPluginLayout() or registerPluginCouchMode() manually. The Vite compiler handles this during the build step. Only raw JS plugins (non-Vue) need manual registration.
Plugin .vue files are compiled outside the main app bundle. You cannot use @/ imports. Instead use window.__GD__:
// Vue runtime - import normally
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
// GD runtime - access via window.__GD__
const _gd = (window as any).__GD__
const client = _gd.api // Axios with Bearer token
const auth = _gd.stores.auth() // { user } - token hidden
const themeStore = _gd.stores.theme() // full theme store
const libs = _gd.stores.libraries() // library registry - data-driven nav/home (see docs/HOOKS.md)
const t = _gd.i18n?.t || ((k: string) => k) // i18n translation
// Couch Mode composables
const { useCouchNav, couchNavPaused } = _gd.composables
const getEjsCore = _gd.getEjsCore // platform -> EmulatorJS core
// Notifications - push badges to user avatar
_gd.notifications.add({ id: 'my-alert', count: 1, label: 'Something happened', action: '/settings' })
_gd.notifications.dismiss('my-alert')Global components available in templates:
<DownloadManager />- Download queue tray (admin only)<RandomGamePicker />- Random game dice button<AmbientBackground />- Animated orb background
Static files in assets/ are served at:
/api/plugins/{plugin-id}/assets/{path}
Helper pattern:
const PLUGIN_ID = 'neon-horizon'
function pluginAsset(path: string): string {
return `/api/plugins/${PLUGIN_ID}/assets/${path}`
}You can also load JSON data from assets via the API client:
const { data } = await client.get('/api/plugins/neon-horizon/assets/platforms.json')
// data is the parsed JSON objectWhen a theme is active, GD sets data-theme and data-skin attributes on the <html> element. Use these to scope your CSS:
/* Target your theme specifically */
[data-theme="my-theme"] {
--my-bg: #0a0a1a;
--my-accent: #00d4ff;
}
/* Target a specific skin within your theme */
[data-theme="my-theme"][data-skin="sunset"] {
--my-accent: #f97316;
}
/* Skin-only selector (also works) */
[data-skin="nh-cyber"] {
--pl: #00d4ff;
--pl-light: #67e8f9;
}This ensures your styles only apply when your theme/skin is active and don't leak into other themes.
Settings in frontend_get_theme() -> settings[] are:
- Rendered in Settings > Appearance > Theme Settings
- Applied as CSS custom properties on
:root - Setting types:
range(slider),select(chips),toggle(on/off) - Labels and hints support i18n keys: use
"label": "nh.my_setting"and define the key in your plugin'si18n.json - For
rangetype: the"unit"field (e.g."px","deg") is appended to the value when setting the CSS variable. So value30with unit"px"produces--my-var: 30px - For
selecttype:"optionLabels"can be i18n keys (e.g.["nh.opt_none", "nh.opt_few"]) - the Settings UI translates them automatically
The frontend_get_js() hook returns a JavaScript string injected into <head>. Since it runs in the global scope, wrap your code in an IIFE to avoid polluting the global namespace:
(function() {
'use strict';
// Helper to read CSS variables set by theme settings
function getCssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
// React to theme setting changes (slider moved, toggle flipped, etc.)
document.documentElement.addEventListener('gd-theme-updated', () => {
const blur = getCssVar('--my-glass-blur');
const particles = getCssVar('--my-particle-count');
// Update your JS effects based on new values
});
// Use MutationObserver to react to DOM changes (SPA route changes, new elements)
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1) {
// Process newly added elements
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Poll CSS variables for initial values (useful on page load)
const count = parseInt(getCssVar('--my-particle-count') || '6', 10);
})();Plugins provide their own translations via an i18n.json file in the plugin root. This file is auto-loaded by the app at startup via GET /api/plugins/frontend/i18n, which merges translations from all installed plugins.
i18n.json format:
{
"en": {
"nh.setting_particles": "Particle Count",
"nh.setting_particles_hint": "Number of floating particles",
"nh.opt_none": "None",
"nh.opt_few": "Few",
"nh.opt_normal": "Normal",
"nh.opt_many": "Many",
"nh.home_title": "Welcome",
"nh.library_empty": "No games found"
},
"pl": {
"nh.setting_particles": "Liczba czastek",
"nh.setting_particles_hint": "Ilosc unoszczych sie czastek",
"nh.opt_none": "Brak",
"nh.opt_few": "Malo",
"nh.opt_normal": "Normalnie",
"nh.opt_many": "Duzo",
"nh.home_title": "Witaj",
"nh.library_empty": "Nie znaleziono gier"
}
}Use a unique prefix for all your keys (e.g. nh.*, retro.*) to avoid collisions with other plugins.
Using translations in Vue components:
const t = _gd.i18n?.t || ((k: string) => k)
// t('nh.home_title') -> "Welcome" (en) or "Witaj" (pl)Using translations in theme settings:
Setting label, hint, and optionLabels fields can reference i18n keys directly. The Settings UI translates them automatically:
"settings": [{
"label": "nh.setting_particles", # translated by Settings UI
"hint": "nh.setting_particles_hint", # translated by Settings UI
"optionLabels": ["nh.opt_none", "nh.opt_few"], # each label translated
}]NEON HORIZON Files:
neon-horizon/
NeonHorizonLayout.vue # Main shell (navbar, particles, route detection)
NeonHorizonHome.vue # Home page (hero banner, recently added rows)
NeonHorizonLibrary.vue # Library view (Big Picture cards, alphabet sidebar)
NeonHorizonSearch.vue # Global search results
NeonHorizonCouch.vue # Couch Mode (Colorful Pop style, 1000+ lines)
neon-horizon.css # CSS overrides for existing GD components
neon-horizon.js # Dynamic effects (gradient text, glass blur observer)
plugin.py # Theme definition - skins, settings, CSS/JS hooks
plugin.json # Manifest
i18n.json # Plugin translations (en + pl)
logo.svg # Plugin icon
assets/
pop/ # Pop character artwork per platform (95 platforms)
overlay/ # Console overlay with TV cutout (RGBA transparency)
artwork/ # Modern console artwork (Ken Burns background)
icons/ # Colored platform icons
logos/ # SVG platform name logos
platforms.json # Platform metadata (names, colors, descriptions in 15 languages)
videopos.json # Video positions inside TV cutouts
| Template | Path | Description |
|---|---|---|
| Metadata Scraper | templates/metadata-scraper/ |
Search + fetch game data from external source |
| Theme | templates/theme/ |
Custom layout with CSS, JS, and settings |
| Lifecycle | templates/lifecycle/ |
Hook into app events, register custom endpoints |
| Widget | templates/widget/ |
Dashboard card with custom content |
Each template has a plugin.py with TODO comments showing where to add your code.
There is no dedicated starter for download (DownloadProviderSpec) or
library source (LibrarySourceSpec) plugins yet, but they follow the exact
same shape: a Plugin class with @hookimpl methods (see the matching sections
in docs/HOOKS.md). Copy the metadata-scraper template and swap
the hooks you implement.
A plugin is distributed as a ZIP archive. Two packaging rules are strict, and getting either wrong makes the install fail:
plugin.jsonMUST be at the archive ROOT, not inside a wrapping folder. The installer reads/plugin.json, somy-plugin/plugin.jsoninside the ZIP will not be found.- Paths inside the ZIP must use forward slashes (
assets/logo.png, notassets\logo.png).
The easiest way is the bundled helper, which enforces both and verifies the result:
./build.sh templates/metadata-scraper # -> my-metadata-scraper-v1.0.0.zipEquivalent by hand on Linux/macOS (note the trailing . and that you cd INTO the folder):
cd my-plugin && zip -r ../my-plugin-v1.0.0.zip . -x "*__pycache__*" "*.pyc"On Windows, do NOT use PowerShell Compress-Archive for this - it writes backslash separators and often nests a top-level folder, both of which break the install. Use Python's zipfile instead (cross-platform, deterministic):
python -c "import zipfile,os; d='my-plugin'; z=zipfile.ZipFile('my-plugin-v1.0.0.zip','w',zipfile.ZIP_DEFLATED); [z.write(os.path.join(r,f), os.path.relpath(os.path.join(r,f),d).replace(os.sep,'/')) for r,_,fs in os.walk(d) for f in fs if '__pycache__' not in r and not f.endswith('.pyc')]; z.close()"Always verify before shipping - plugin.json must appear with no path prefix:
unzip -l my-plugin-v1.0.0.zip | grep plugin.json # -> "... plugin.json", NOT "... my-plugin/plugin.json"Then either:
- Users install it via Settings > Plugins (drag and drop the ZIP), or
- Publish it to a Plugin Store - see gd3-plugin-store for the
store.jsonformat (the ZIP becomes a release asset).
GamesDownloader V3 and its plugin system were inspired by several outstanding open-source projects:
- RomM - ROM management platform that inspired the emulation library architecture, metadata scraping approach, and platform organization
- Gameyfin - Self-hosted game library manager that inspired the original concept of a personal game vault with automatic metadata fetching
NEON HORIZON Couch Mode uses assets from EmulationStation themes by RobZombie9043:
- Colorful Pop - Platform artwork, SVG logos, colored icons, platform metadata with 15-language descriptions, video positioning data, and system color palettes
- Elementerial - Additional design inspiration
These EmulationStation themes are licensed under Creative Commons CC-BY-NC-SA. The assets are included for non-commercial, personal use. All credit for original artwork goes to RobZombie9043.
Special thanks to the teams behind EmulatorJS, ScreenScraper, SteamGridDB, and HowLongToBeat for their APIs and services.
MIT (plugin template code). Theme assets may have separate licenses - see credits above.