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": "3.0.0",
"config_schema": {
"api_key": {
"type": "string",
"default": "",
"label": "API Key"
},
"enabled": {
"type": "boolean",
"default": true,
"label": "Enable"
}
}
}Config schema fields are rendered as a settings form in Settings > Plugins. Supported types: string, number, boolean, select.
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"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 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.
To distribute your plugin:
- ZIP your plugin folder:
cd my-plugin && zip -r ../my-plugin-v1.0.0.zip . - Users install via Settings > Plugins (drag & drop ZIP)
- Or publish to a Plugin Store - see gd3-plugin-store for the store.json format
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.