Merge pull request #385 from Dispatcharr:dev

Dispatcharr Release Notes – Version 0.9.1
This commit is contained in:
SergeantPanda 2025-09-13 12:09:20 -05:00 committed by GitHub
commit 59379ae59a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 497 additions and 124 deletions

View file

@ -1857,7 +1857,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
except Exception:
pass
library_dir = '/app/data'
library_dir = '/data'
allowed_roots = ['/data/', library_dir.rstrip('/') + '/']
def _safe_remove(path: str):

View file

@ -578,8 +578,8 @@ def _build_output_paths(channel, program, start_time, end_time):
Build (final_path, temp_ts_path, final_filename) using DVR templates.
"""
from core.models import CoreSettings
# Root for DVR recordings: fixed to /app/data inside the container
library_root = '/app/data'
# Root for DVR recordings: fixed to /data/recordings inside the container
library_root = '/data/recordings'
is_movie, season, episode, year, sub_title = _parse_epg_tv_movie_info(program)
show = _safe_name(program.get('title') if isinstance(program, dict) else channel.name)
@ -632,7 +632,7 @@ def _build_output_paths(channel, program, start_time, end_time):
if not is_movie and not rel_path:
rel_path = f"TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
# Keep any leading folder like 'Recordings/' from the template so users can
# structure their library under /app/data as desired.
# structure their library under /data as desired.
if not rel_path.lower().endswith('.mkv'):
rel_path = f"{rel_path}.mkv"

View file

@ -26,7 +26,7 @@ class LoadedPlugin:
class PluginManager:
"""Singleton manager that discovers and runs plugins from /app/data/plugins."""
"""Singleton manager that discovers and runs plugins from /data/plugins."""
_instance: Optional["PluginManager"] = None
@ -37,7 +37,7 @@ class PluginManager:
return cls._instance
def __init__(self) -> None:
self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/app/data/plugins")
self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins")
self._registry: Dict[str, LoadedPlugin] = {}
# Ensure plugins directory exists

View file

@ -1,25 +1,29 @@
# Generated by Django 5.2.4 on 2025-09-13 13:51
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
dependencies = [
]
operations = [
migrations.CreateModel(
name="PluginConfig",
name='PluginConfig',
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("key", models.CharField(max_length=128, unique=True)),
("name", models.CharField(max_length=255)),
("version", models.CharField(blank=True, default="", max_length=64)),
("description", models.TextField(blank=True, default="")),
("enabled", models.BooleanField(default=False)), # merged change
("ever_enabled", models.BooleanField(default=False)), # merged addition
("settings", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=128, unique=True)),
('name', models.CharField(max_length=255)),
('version', models.CharField(blank=True, default='', max_length=64)),
('description', models.TextField(blank=True, default='')),
('enabled', models.BooleanField(default=False)),
('ever_enabled', models.BooleanField(default=False)),
('settings', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
)
),
]

View file

@ -0,0 +1 @@
# This file marks the migrations package for the plugins app.

View file

@ -0,0 +1,61 @@
# Generated manually to update DVR template paths
from django.db import migrations
from django.utils.text import slugify
def update_dvr_template_paths(apps, schema_editor):
"""Remove 'Recordings/' prefix from DVR template paths"""
CoreSettings = apps.get_model("core", "CoreSettings")
# Define the updates needed
updates = [
(slugify("DVR TV Template"), "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
(slugify("DVR Movie Template"), "Movies/{title} ({year}).mkv"),
(slugify("DVR TV Fallback Template"), "TV_Shows/{show}/{start}.mkv"),
(slugify("DVR Movie Fallback Template"), "Movies/{start}.mkv"),
]
# Update each setting
for key, new_value in updates:
try:
setting = CoreSettings.objects.get(key=key)
setting.value = new_value
setting.save()
print(f"Updated {setting.name}: {new_value}")
except CoreSettings.DoesNotExist:
print(f"Setting with key '{key}' not found - skipping")
def reverse_dvr_template_paths(apps, schema_editor):
"""Add back 'Recordings/' prefix to DVR template paths"""
CoreSettings = apps.get_model("core", "CoreSettings")
# Define the reverse updates (add back Recordings/ prefix)
updates = [
(slugify("DVR TV Template"), "Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
(slugify("DVR Movie Template"), "Recordings/Movies/{title} ({year}).mkv"),
(slugify("DVR TV Fallback Template"), "Recordings/TV_Shows/{show}/{start}.mkv"),
(slugify("DVR Movie Fallback Template"), "Recordings/Movies/{start}.mkv"),
]
# Update each setting back to original
for key, original_value in updates:
try:
setting = CoreSettings.objects.get(key=key)
setting.value = original_value
setting.save()
print(f"Reverted {setting.name}: {original_value}")
except CoreSettings.DoesNotExist:
print(f"Setting with key '{key}' not found - skipping")
class Migration(migrations.Migration):
dependencies = [
("core", "0015_dvr_templates"),
]
operations = [
migrations.RunPython(update_dvr_template_paths, reverse_dvr_template_paths),
]

View file

@ -255,7 +255,7 @@ class CoreSettings(models.Model):
return cls.objects.get(key=DVR_TV_FALLBACK_TEMPLATE_KEY).value
except cls.DoesNotExist:
# default requested by user
return "Recordings/TV_Shows/{show}/{start}.mkv"
return "TV_Shows/{show}/{start}.mkv"
@classmethod
def get_dvr_movie_fallback_template(cls):
@ -263,7 +263,7 @@ class CoreSettings(models.Model):
try:
return cls.objects.get(key=DVR_MOVIE_FALLBACK_TEMPLATE_KEY).value
except cls.DoesNotExist:
return "Recordings/Movies/{start}.mkv"
return "Movies/{start}.mkv"
@classmethod
def get_dvr_comskip_enabled(cls):

View file

@ -6,6 +6,7 @@ mkdir -p /data/uploads/m3us
mkdir -p /data/uploads/epgs
mkdir -p /data/m3us
mkdir -p /data/epgs
mkdir -p /data/plugins
mkdir -p /app/logo_cache
mkdir -p /app/media

View file

@ -49,7 +49,10 @@ const Field = ({ field, value, onChange }) => {
return (
<Select
value={(value ?? field.default ?? '') + ''}
data={(field.options || []).map((o) => ({ value: o.value + '', label: o.label }))}
data={(field.options || []).map((o) => ({
value: o.value + '',
label: o.label,
}))}
onChange={(v) => onChange(field.id, v)}
{...common}
/>
@ -66,14 +69,25 @@ const Field = ({ field, value, onChange }) => {
}
};
const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRequireTrust, onRequestDelete }) => {
const PluginCard = ({
plugin,
onSaveSettings,
onRunAction,
onToggleEnabled,
onRequireTrust,
onRequestDelete,
}) => {
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [enabled, setEnabled] = useState(!!plugin.enabled);
const [lastResult, setLastResult] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmConfig, setConfirmConfig] = useState({ title: '', message: '', onConfirm: null });
const [confirmConfig, setConfirmConfig] = useState({
title: '',
message: '',
onConfirm: null,
});
// Keep local enabled state in sync with props (e.g., after import + enable)
React.useEffect(() => {
@ -92,7 +106,11 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRe
setSaving(true);
try {
await onSaveSettings(plugin.key, settings);
notifications.show({ title: 'Saved', message: `${plugin.name} settings updated`, color: 'green' });
notifications.show({
title: 'Saved',
message: `${plugin.name} settings updated`,
color: 'green',
});
} finally {
setSaving(false);
}
@ -100,17 +118,31 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRe
const missing = plugin.missing;
return (
<Card shadow="sm" radius="md" withBorder style={{ opacity: !missing && enabled ? 1 : 0.6 }}>
<Card
shadow="sm"
radius="md"
withBorder
style={{ opacity: !missing && enabled ? 1 : 0.6 }}
>
<Group justify="space-between" mb="xs" align="center">
<div>
<Text fw={600}>{plugin.name}</Text>
<Text size="sm" c="dimmed">{plugin.description}</Text>
<Text size="sm" c="dimmed">
{plugin.description}
</Text>
</div>
<Group gap="xs" align="center">
<ActionIcon variant="subtle" color="red" title="Delete plugin" onClick={() => onRequestDelete && onRequestDelete(plugin)}>
<ActionIcon
variant="subtle"
color="red"
title="Delete plugin"
onClick={() => onRequestDelete && onRequestDelete(plugin)}
>
<Trash2 size={16} />
</ActionIcon>
<Text size="xs" c="dimmed">v{plugin.version || '1.0.0'}</Text>
<Text size="xs" c="dimmed">
v{plugin.version || '1.0.0'}
</Text>
<Switch
checked={!missing && enabled}
onChange={async (e) => {
@ -146,10 +178,17 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRe
{!missing && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
{plugin.fields.map((f) => (
<Field key={f.id} field={f} value={settings?.[f.id]} onChange={updateField} />
<Field
key={f.id}
field={f}
value={settings?.[f.id]}
onChange={updateField}
/>
))}
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">Save Settings</Button>
<Button loading={saving} onClick={save} variant="default" size="xs">
Save Settings
</Button>
</Group>
</Stack>
)}
@ -163,7 +202,9 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRe
<div>
<Text>{a.label}</Text>
{a.description && (
<Text size="sm" c="dimmed">{a.description}</Text>
<Text size="sm" c="dimmed">
{a.description}
</Text>
)}
</div>
<Button
@ -175,7 +216,9 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRe
try {
// Determine if confirmation is required from action metadata or fallback field
const actionConfirm = a.confirm;
const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
const confirmField = (plugin.fields || []).find(
(f) => f.id === 'confirm'
);
let requireConfirm = false;
let confirmTitle = `Run ${a.label}?`;
let confirmMessage = `You're about to run "${a.label}" from "${plugin.name}".`;
@ -184,33 +227,55 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRe
requireConfirm = actionConfirm;
} else if (typeof actionConfirm === 'object') {
requireConfirm = actionConfirm.required !== false;
if (actionConfirm.title) confirmTitle = actionConfirm.title;
if (actionConfirm.message) confirmMessage = actionConfirm.message;
if (actionConfirm.title)
confirmTitle = actionConfirm.title;
if (actionConfirm.message)
confirmMessage = actionConfirm.message;
}
} else if (confirmField) {
const settingVal = settings?.confirm;
const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
const effectiveConfirm =
(settingVal !== undefined
? settingVal
: confirmField.default) ?? false;
requireConfirm = !!effectiveConfirm;
}
if (requireConfirm) {
await new Promise((resolve) => {
setConfirmConfig({ title: confirmTitle, message: confirmMessage, onConfirm: resolve });
setConfirmConfig({
title: confirmTitle,
message: confirmMessage,
onConfirm: resolve,
});
setConfirmOpen(true);
});
}
// Save settings before running to ensure backend uses latest values
try { await onSaveSettings(plugin.key, settings); } catch (e) { /* ignore, run anyway */ }
try {
await onSaveSettings(plugin.key, settings);
} catch (e) {
/* ignore, run anyway */
}
const resp = await onRunAction(plugin.key, a.id);
if (resp?.success) {
setLastResult(resp.result || {});
const msg = resp.result?.message || 'Plugin action completed';
notifications.show({ title: plugin.name, message: msg, color: 'green' });
const msg =
resp.result?.message || 'Plugin action completed';
notifications.show({
title: plugin.name,
message: msg,
color: 'green',
});
} else {
const err = resp?.error || 'Unknown error';
setLastResult({ error: err });
notifications.show({ title: `${plugin.name} error`, message: String(err), color: 'red' });
notifications.show({
title: `${plugin.name} error`,
message: String(err),
color: 'red',
});
}
} finally {
setRunning(false);
@ -223,25 +288,54 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRe
</Group>
))}
{running && (
<Text size="sm" c="dimmed">Running action please wait</Text>
<Text size="sm" c="dimmed">
Running action please wait
</Text>
)}
{!running && lastResult?.file && (
<Text size="sm" c="dimmed">Output: {lastResult.file}</Text>
<Text size="sm" c="dimmed">
Output: {lastResult.file}
</Text>
)}
{!running && lastResult?.error && (
<Text size="sm" c="red">Error: {String(lastResult.error)}</Text>
<Text size="sm" c="red">
Error: {String(lastResult.error)}
</Text>
)}
</Stack>
</>
)}
<Modal opened={confirmOpen} onClose={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }} title={confirmConfig.title} centered>
<Modal
opened={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', onConfirm: null });
}}
title={confirmConfig.title}
centered
>
<Stack>
<Text size="sm">{confirmConfig.message}</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }}>
<Button
variant="default"
size="xs"
onClick={() => {
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', onConfirm: null });
}}
>
Cancel
</Button>
<Button size="xs" onClick={() => { const cb = confirmConfig.onConfirm; setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); cb && cb(true); }}>
<Button
size="xs"
onClick={() => {
const cb = confirmConfig.onConfirm;
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', onConfirm: null });
cb && cb(true);
}}
>
Confirm
</Button>
</Group>
@ -290,12 +384,30 @@ export default function PluginsPage() {
return (
<AppShell.Main style={{ padding: 16 }}>
<Group justify="space-between" mb="md">
<Text fw={700} size="lg">Plugins</Text>
<Text fw={700} size="lg">
Plugins
</Text>
<Group>
<Button size="xs" variant="light" onClick={() => { setImportOpen(true); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>
<Button
size="xs"
variant="light"
onClick={() => {
setImportOpen(true);
setImported(null);
setImportFile(null);
setEnableAfterImport(false);
}}
>
Import Plugin
</Button>
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
<ActionIcon
variant="light"
onClick={async () => {
await API.reloadPlugins();
await load();
}}
title="Reload"
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
@ -305,7 +417,12 @@ export default function PluginsPage() {
<Loader />
) : (
<>
<SimpleGrid cols={2} spacing="md" verticalSpacing="md" breakpoints={[{ maxWidth: '48em', cols: 1 }]}>
<SimpleGrid
cols={2}
spacing="md"
verticalSpacing="md"
breakpoints={[{ maxWidth: '48em', cols: 1 }]}
>
{plugins.map((p) => (
<PluginCard
key={p.key}
@ -315,93 +432,219 @@ export default function PluginsPage() {
onToggleEnabled={async (key, next) => {
const resp = await API.setPluginEnabled(key, next);
if (resp?.ever_enabled !== undefined) {
setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, ever_enabled: resp.ever_enabled, enabled: resp.enabled } : pl));
setPlugins((prev) =>
prev.map((pl) =>
pl.key === key
? {
...pl,
ever_enabled: resp.ever_enabled,
enabled: resp.enabled,
}
: pl
)
);
} else {
setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, enabled: next } : pl));
setPlugins((prev) =>
prev.map((pl) =>
pl.key === key ? { ...pl, enabled: next } : pl
)
);
}
return resp;
}}
onRequireTrust={requireTrust}
onRequestDelete={(plugin) => { setDeleteTarget(plugin); setDeleteOpen(true); }}
onRequestDelete={(plugin) => {
setDeleteTarget(plugin);
setDeleteOpen(true);
}}
/>
))}
</SimpleGrid>
{plugins.length === 0 && (
<Box>
<Text c="dimmed">No plugins found. Drop a plugin into <code>/app/data/plugins</code> and reload.</Text>
<Text c="dimmed">
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
and reload.
</Text>
</Box>
)}
</>
)}
{/* Import Plugin Modal */}
<Modal opened={importOpen} onClose={() => setImportOpen(false)} title="Import Plugin" centered>
<Modal
opened={importOpen}
onClose={() => setImportOpen(false)}
title="Import Plugin"
centered
>
<Stack>
<Text size="sm" c="dimmed">Upload a ZIP containing your plugin folder or package.</Text>
<Text size="sm" c="dimmed">
Upload a ZIP containing your plugin folder or package.
</Text>
<Alert color="yellow" variant="light" title="Heads up">
Importing a plugin may briefly restart the backend (you might see a temporary disconnect). Please wait a few seconds and the app will reconnect automatically.
Importing a plugin may briefly restart the backend (you might see a
temporary disconnect). Please wait a few seconds and the app will
reconnect automatically.
</Alert>
<Dropzone onDrop={(files) => files[0] && setImportFile(files[0])} onReject={() => {}}
<Dropzone
onDrop={(files) => files[0] && setImportFile(files[0])}
onReject={() => {}}
maxFiles={1}
accept={['application/zip', 'application/x-zip-compressed', 'application/octet-stream']}
accept={[
'application/zip',
'application/x-zip-compressed',
'application/octet-stream',
]}
multiple={false}
>
<Group justify="center" mih={80}>
<Text size="sm">Drag and drop plugin .zip here</Text>
</Group>
</Dropzone>
<FileInput placeholder="Select plugin .zip" value={importFile} onChange={setImportFile} accept=".zip" clearable />
<FileInput
placeholder="Select plugin .zip"
value={importFile}
onChange={setImportFile}
accept=".zip"
clearable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setImportOpen(false)} size="xs">Close</Button>
<Button size="xs" loading={importing} disabled={!importFile} onClick={async () => {
setImporting(true);
const id = notifications.show({ title: 'Uploading plugin', message: 'Backend may restart; please wait…', loading: true, autoClose: false, withCloseButton: false });
setUploadNoticeId(id);
try {
const resp = await API.importPlugin(importFile);
if (resp?.success && resp.plugin) {
setImported(resp.plugin);
setPlugins((prev) => [resp.plugin, ...prev.filter((p) => p.key !== resp.plugin.key)]);
notifications.update({ id, loading: false, color: 'green', title: 'Imported', message: 'Plugin imported. If the app briefly disconnected, it should be back now.', autoClose: 3000 });
} else {
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: (resp?.error || 'Unknown error'), autoClose: 5000 });
<Button
variant="default"
onClick={() => setImportOpen(false)}
size="xs"
>
Close
</Button>
<Button
size="xs"
loading={importing}
disabled={!importFile}
onClick={async () => {
setImporting(true);
const id = notifications.show({
title: 'Uploading plugin',
message: 'Backend may restart; please wait…',
loading: true,
autoClose: false,
withCloseButton: false,
});
setUploadNoticeId(id);
try {
const resp = await API.importPlugin(importFile);
if (resp?.success && resp.plugin) {
setImported(resp.plugin);
setPlugins((prev) => [
resp.plugin,
...prev.filter((p) => p.key !== resp.plugin.key),
]);
notifications.update({
id,
loading: false,
color: 'green',
title: 'Imported',
message:
'Plugin imported. If the app briefly disconnected, it should be back now.',
autoClose: 3000,
});
} else {
notifications.update({
id,
loading: false,
color: 'red',
title: 'Import failed',
message: resp?.error || 'Unknown error',
autoClose: 5000,
});
}
} catch (e) {
// API.importPlugin already showed a concise error; just update the loading notice
notifications.update({
id,
loading: false,
color: 'red',
title: 'Import failed',
message:
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed',
autoClose: 5000,
});
} finally {
setImporting(false);
setUploadNoticeId(null);
}
} catch (e) {
// API.importPlugin already showed a concise error; just update the loading notice
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: ((e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed'), autoClose: 5000 });
} finally {
setImporting(false);
setUploadNoticeId(null);
}
}}>Upload</Button>
}}
>
Upload
</Button>
</Group>
{imported && (
<Box>
<Divider my="sm" />
<Text fw={600}>{imported.name}</Text>
<Text size="sm" c="dimmed">{imported.description}</Text>
<Text size="sm" c="dimmed">
{imported.description}
</Text>
<Group justify="space-between" mt="sm" align="center">
<Text size="sm">Enable now</Text>
<Switch size="sm" checked={enableAfterImport} onChange={(e) => setEnableAfterImport(e.currentTarget.checked)} />
<Switch
size="sm"
checked={enableAfterImport}
onChange={(e) =>
setEnableAfterImport(e.currentTarget.checked)
}
/>
</Group>
<Group justify="flex-end" mt="md">
<Button variant="default" size="xs" onClick={() => { setImportOpen(false); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>Done</Button>
<Button size="xs" disabled={!enableAfterImport} onClick={async () => {
if (!imported) return;
let proceed = true;
if (!imported.ever_enabled) {
proceed = await requireTrust(imported);
}
if (proceed) {
const resp = await API.setPluginEnabled(imported.key, true);
if (resp?.success) {
setPlugins((prev) => prev.map((p) => p.key === imported.key ? { ...p, enabled: true, ever_enabled: true } : p));
notifications.show({ title: imported.name, message: 'Plugin enabled', color: 'green' });
}
<Button
variant="default"
size="xs"
onClick={() => {
setImportOpen(false);
setImported(null);
setImportFile(null);
setEnableAfterImport(false);
}
}}>Enable</Button>
}}
>
Done
</Button>
<Button
size="xs"
disabled={!enableAfterImport}
onClick={async () => {
if (!imported) return;
let proceed = true;
if (!imported.ever_enabled) {
proceed = await requireTrust(imported);
}
if (proceed) {
const resp = await API.setPluginEnabled(
imported.key,
true
);
if (resp?.success) {
setPlugins((prev) =>
prev.map((p) =>
p.key === imported.key
? { ...p, enabled: true, ever_enabled: true }
: p
)
);
notifications.show({
title: imported.name,
message: 'Plugin enabled',
color: 'green',
});
}
setImportOpen(false);
setImported(null);
setEnableAfterImport(false);
}
}}
>
Enable
</Button>
</Group>
</Box>
)}
@ -409,42 +652,105 @@ export default function PluginsPage() {
</Modal>
{/* Trust Warning Modal */}
<Modal opened={trustOpen} onClose={() => { setTrustOpen(false); trustResolve && trustResolve(false); }} title="Enable third-party plugins?" centered>
<Modal
opened={trustOpen}
onClose={() => {
setTrustOpen(false);
trustResolve && trustResolve(false);
}}
title="Enable third-party plugins?"
centered
>
<Stack>
<Text size="sm">
Plugins run server-side code with full access to your Dispatcharr instance and its data. Only enable plugins from developers you trust.
Plugins run server-side code with full access to your Dispatcharr
instance and its data. Only enable plugins from developers you
trust.
</Text>
<Text size="sm" c="dimmed">
Why: Malicious plugins could read or modify data, call internal APIs, or perform unwanted actions. Review the source or trust the author before enabling.
Why: Malicious plugins could read or modify data, call internal
APIs, or perform unwanted actions. Review the source or trust the
author before enabling.
</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(false); }}>Cancel</Button>
<Button size="xs" color="red" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(true); }}>I understand, enable</Button>
<Button
variant="default"
size="xs"
onClick={() => {
setTrustOpen(false);
trustResolve && trustResolve(false);
}}
>
Cancel
</Button>
<Button
size="xs"
color="red"
onClick={() => {
setTrustOpen(false);
trustResolve && trustResolve(true);
}}
>
I understand, enable
</Button>
</Group>
</Stack>
</Modal>
{/* Delete Plugin Modal */}
<Modal opened={deleteOpen} onClose={() => { setDeleteOpen(false); setDeleteTarget(null); }} title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'} centered>
<Modal
opened={deleteOpen}
onClose={() => {
setDeleteOpen(false);
setDeleteTarget(null);
}}
title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'}
centered
>
<Stack>
<Text size="sm">This will remove the plugin files and its configuration. This action cannot be undone.</Text>
<Text size="sm">
This will remove the plugin files and its configuration. This action
cannot be undone.
</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setDeleteOpen(false); setDeleteTarget(null); }}>Cancel</Button>
<Button size="xs" color="red" loading={deleting} onClick={async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const resp = await API.deletePlugin(deleteTarget.key);
if (resp?.success) {
setPlugins((prev) => prev.filter((p) => p.key !== deleteTarget.key));
notifications.show({ title: deleteTarget.name, message: 'Plugin deleted', color: 'green' });
}
<Button
variant="default"
size="xs"
onClick={() => {
setDeleteOpen(false);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
}}>Delete</Button>
}}
>
Cancel
</Button>
<Button
size="xs"
color="red"
loading={deleting}
onClick={async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const resp = await API.deletePlugin(deleteTarget.key);
if (resp?.success) {
setPlugins((prev) =>
prev.filter((p) => p.key !== deleteTarget.key)
);
notifications.show({
title: deleteTarget.name,
message: 'Plugin deleted',
color: 'green',
});
}
setDeleteOpen(false);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
}}
>
Delete
</Button>
</Group>
</Stack>
</Modal>

View file

@ -484,7 +484,7 @@ const SettingsPage = () => {
<TextInput
label="TV Path Template"
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."
placeholder="Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
placeholder="TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
{...form.getInputProps('dvr-tv-template')}
key={form.key('dvr-tv-template')}
id={
@ -497,7 +497,7 @@ const SettingsPage = () => {
<TextInput
label="TV Fallback Template"
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
placeholder="Recordings/TV_Shows/{show}/{start}.mkv"
placeholder="TV_Shows/{show}/{start}.mkv"
{...form.getInputProps('dvr-tv-fallback-template')}
key={form.key('dvr-tv-fallback-template')}
id={
@ -512,7 +512,7 @@ const SettingsPage = () => {
<TextInput
label="Movie Path Template"
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
placeholder="Recordings/Movies/{title} ({year}).mkv"
placeholder="Movies/{title} ({year}).mkv"
{...form.getInputProps('dvr-movie-template')}
key={form.key('dvr-movie-template')}
id={
@ -527,7 +527,7 @@ const SettingsPage = () => {
<TextInput
label="Movie Fallback Template"
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
placeholder="Recordings/Movies/{start}.mkv"
placeholder="Movies/{start}.mkv"
{...form.getInputProps('dvr-movie-fallback-template')}
key={form.key('dvr-movie-fallback-template')}
id={