Dispatcharr’s plugin system enables you to extend the platform with custom Python code that runs server-side with full access to Django models, Celery tasks, and internal APIs.
Quick Start
Create plugin directory
Create a folder structure for your plugin: mkdir -p data/plugins/my_plugin
cd data/plugins/my_plugin
Add plugin manifest
Create plugin.json for safe metadata discovery: {
"name" : "My Plugin" ,
"version" : "0.1.0" ,
"description" : "Does something useful" ,
"author" : "Acme Labs" ,
"help_url" : "https://example.com/docs/my-plugin" ,
"fields" : [
{ "id" : "enabled" , "label" : "Enabled" , "type" : "boolean" , "default" : true },
{ "id" : "limit" , "label" : "Item limit" , "type" : "number" , "default" : 5 }
],
"actions" : [
{
"id" : "do_work" ,
"label" : "Do Work" ,
"description" : "Process items" ,
"button_label" : "Run Job" ,
"button_variant" : "filled" ,
"button_color" : "blue"
}
]
}
Implement plugin class
Create plugin.py with a Plugin class: class Plugin :
name = "My Plugin"
version = "0.1.0"
description = "Does something useful"
author = "Acme Labs"
help_url = "https://example.com/docs/my-plugin"
fields = [
{ "id" : "enabled" , "label" : "Enabled" , "type" : "boolean" , "default" : True },
{ "id" : "limit" , "label" : "Item limit" , "type" : "number" , "default" : 5 },
]
actions = [
{
"id" : "do_work" ,
"label" : "Do Work" ,
"description" : "Process items" ,
"button_label" : "Run Job" ,
"button_variant" : "filled" ,
"button_color" : "blue" ,
},
]
def run ( self , action : str , params : dict , context : dict ):
settings = context.get( "settings" , {})
logger = context.get( "logger" )
if action == "do_work" :
limit = int (settings.get( "limit" , 5 ))
logger.info( f "My Plugin running with limit= { limit } " )
return { "status" : "ok" , "processed" : limit}
return { "status" : "error" , "message" : f "Unknown action { action } " }
Load and enable
Open the Plugins page in the UI, click the refresh icon to reload discovery, then enable your plugin.
Plugin Structure
Directory Layout
Plugins live in /app/data/plugins (default) or a custom path via DISPATCHARR_PLUGINS_DIR:
data/plugins/
├── my_plugin/
│ ├── plugin.json # Manifest (required for v0.19+)
│ ├── plugin.py # Plugin class
│ └── logo.png # Optional logo
└── another_plugin/
├── __init__.py # Can use package structure
├── plugin.json
└── helpers.py
The directory name (lowercased, spaces as _) becomes the plugin key used in API calls.
Plugin Manifest
The plugin.json file allows Dispatcharr to list your plugin safely without executing code:
{
"name" : "My Plugin" ,
"version" : "1.2.3" ,
"description" : "Does something useful" ,
"author" : "Acme Labs" , // Optional: shown on plugin card
"help_url" : "https://example.com/docs" , // Optional: adds Docs link
"fields" : [ ... ], // Settings schema
"actions" : [ ... ] // Available actions
}
Plugins without plugin.json are marked as legacy and show a warning in the UI.
Plugin Interface
Your Plugin class supports these attributes:
Attribute Type Description namestrHuman-readable name versionstrSemantic version string descriptionstrShort description authorstrAuthor or team name (optional) help_urlstrDocumentation link (optional) fieldslistSettings schema for UI controls actionslistAvailable actions rendered as buttons run(action, params, context)callableInvoked when user clicks action stop(context)callableOptional graceful shutdown hook
Settings Schema
Define form fields that are rendered in the UI and persisted automatically:
fields = [
{ "id" : "enabled" , "label" : "Enabled" , "type" : "boolean" , "default" : True },
{ "id" : "limit" , "label" : "Item limit" , "type" : "number" , "default" : 5 },
{
"id" : "mode" ,
"label" : "Mode" ,
"type" : "select" ,
"default" : "safe" ,
"options" : [
{ "value" : "safe" , "label" : "Safe" },
{ "value" : "fast" , "label" : "Fast" },
]
},
{ "id" : "api_key" , "label" : "API Key" , "type" : "string" , "input_type" : "password" },
{ "id" : "note" , "label" : "Instructions" , "type" : "text" , "help_text" : "Multi-line notes" },
{ "id" : "info" , "label" : "" , "type" : "info" , "description" : "This is a heading or note" },
]
Supported field types:
boolean - Checkbox
number - Numeric input
string - Single-line text (use input_type: "password" to mask)
text - Multi-line textarea
select - Dropdown (requires options)
info - Display-only text for headings/notes
Actions
Actions appear as buttons in the UI. Clicking a button calls run(action_id, params, context):
actions = [
{
"id" : "refresh_all" ,
"label" : "Refresh All Sources" ,
"description" : "Queues background refresh for all M3U and EPG" ,
"button_label" : "Run Refresh" ,
"button_variant" : "filled" , // filled, outline, subtle
"button_color" : "blue" , // red, blue, orange, etc.
"confirm" : { // Optional confirmation modal
"required" : True ,
"title" : "Proceed?" ,
"message" : "This will refresh all sources."
}
},
]
Use button_variant and button_color to style action buttons. For destructive actions, use "button_color": "red" with a confirmation modal.
Accessing Dispatcharr APIs
Plugins run inside the Django process with full access to models and tasks.
Import Models
from apps.m3u.models import M3UAccount
from apps.epg.models import EPGSource
from apps.channels.models import Channel
from core.models import CoreSettings
def run ( self , action , params , context ):
channels = Channel.objects.all().values( "id" , "name" , "number" )[: 50 ]
return { "status" : "ok" , "channels" : list (channels)}
Dispatch Celery Tasks
Keep run() fast and non-blocking. Schedule heavy operations via Celery tasks.
from apps.m3u.tasks import refresh_m3u_accounts
from apps.epg.tasks import refresh_all_epg_data
def run ( self , action , params , context ):
if action == "refresh_all" :
refresh_m3u_accounts.delay()
refresh_all_epg_data.delay()
return { "status" : "queued" , "message" : "Refresh jobs queued" }
Send WebSocket Updates
from core.utils import send_websocket_update
send_websocket_update(
"updates" ,
"update" ,
{ "type" : "plugin" , "plugin" : "my_plugin" , "message" : "Refresh queued" }
)
Use Transactions
from django.db import transaction
with transaction.atomic():
# bulk updates here
Channel.objects.filter( is_enabled = False ).delete()
Example: Refresh All Sources Plugin
data/plugins/refresh_all/plugin.py
class Plugin :
name = "Refresh All Sources"
version = "1.0.0"
description = "Force refresh all M3U accounts and EPG sources."
fields = [
{ "id" : "confirm" , "label" : "Require confirmation" , "type" : "boolean" , "default" : True ,
"help_text" : "If enabled, the UI will ask before running." }
]
actions = [
{ "id" : "refresh_all" , "label" : "Refresh All M3Us and EPGs" ,
"description" : "Queues background refresh for all active M3U accounts and EPG sources." }
]
def run ( self , action : str , params : dict , context : dict ):
if action == "refresh_all" :
from apps.m3u.tasks import refresh_m3u_accounts
from apps.epg.tasks import refresh_all_epg_data
refresh_m3u_accounts.delay()
refresh_all_epg_data.delay()
return { "status" : "queued" , "message" : "Refresh jobs queued" }
return { "status" : "error" , "message" : f "Unknown action: { action } " }
Graceful Shutdown
Implement stop() to clean up when the plugin is disabled or reloaded:
import signal, os
class Plugin :
name = "Example Plugin"
version = "1.0.0"
description = "Shows how to shut down gracefully."
def run ( self , action : str , params : dict , context : dict ):
# Start a subprocess or background task and store its PID
# ...
return { "status" : "ok" }
def stop ( self , context : dict ):
logger = context.get( "logger" )
pid = self ._read_pid() # your helper
if pid:
try :
os.kill(pid, signal. SIGTERM )
logger.info( "Stopped process %s " , pid)
except Exception :
logger.exception( "Failed to stop process %s " , pid)
Event-Driven Plugins
Plugins can subscribe to system events by specifying an events array in action definitions:
actions = [
{
"id" : "on_channel_start" ,
"label" : "Handle Channel Start" ,
"events" : [ "channel_start" , "channel_reconnect" ], # Subscribe to events
},
]
def run ( self , action : str , params : dict , context : dict ):
if action == "on_channel_start" :
event = params.get( "event" ) # e.g. "channel_start"
payload = params.get( "payload" ) # Event data
channel_id = payload.get( "channel_id" )
channel_name = payload.get( "channel_name" )
# Custom logic here
return { "status" : "handled" }
channel_start - Channel started
channel_stop - Channel stopped
channel_reconnect - Channel reconnected
channel_error - Channel error
channel_failover - Channel failover
stream_switch - Stream switched
recording_start - Recording started
recording_end - Recording ended
epg_refresh - EPG refreshed
m3u_refresh - M3U refreshed
client_connect - Client connected
client_disconnect - Client disconnected
Importing Plugins
Package your plugin
Create a .zip file containing your plugin folder: cd data/plugins
zip -r my_plugin.zip my_plugin/
Upload via UI
In the Plugins page, click Import and upload your .zip file.
Enable the plugin
After import, toggle the enable switch. The first time you enable a plugin, Dispatcharr shows a trust warning.
Plugins run with full application permissions. Only install plugins from trusted sources.
REST API Endpoints
Manage plugins programmatically:
Endpoint Method Description /api/plugins/plugins/GETList all plugins /api/plugins/plugins/reload/POSTReload plugin discovery /api/plugins/plugins/import/POSTImport plugin from ZIP /api/plugins/plugins/<key>/settings/POSTUpdate plugin settings /api/plugins/plugins/<key>/run/POSTRun plugin action /api/plugins/plugins/<key>/enabled/POSTEnable/disable plugin
Security Best Practices
Never ask users for Dispatcharr URL/credentials in plugin settings. Plugins run inside the backend with direct model/task access.
Validate and sanitize all params from UI
Use database transactions for bulk operations
Only write files under /data paths
Log actionable messages for troubleshooting
Keep dependencies minimal (vendor small helpers if needed)
Troubleshooting
Ensure the folder contains plugin.py or __init__.py with a Plugin class. Click the refresh icon in the Plugins page.
Check Docker logs: docker logs dispatcharr. Verify Python syntax and imports.
The plugin is disabled. Enable it via the toggle in the UI or the /enabled/ endpoint.
Check field id values match in both plugin.json and plugin.py. Verify field types are valid.
Reference Implementation
See the plugin loader source code:
Loader: apps/plugins/loader.py:1
Model: apps/plugins/models.py:4
API Views: apps/plugins/api_views.py
Frontend: frontend/src/pages/Plugins.jsx