mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Fix incorrect paths for DVR and Plugins.
This commit is contained in:
parent
75816b5d8e
commit
41d7066d6e
8 changed files with 480 additions and 112 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
core/migrations/0016_update_dvr_template_paths.py
Normal file
61
core/migrations/0016_update_dvr_template_paths.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
#!/bin/bash
|
||||
|
||||
mkdir -p /data/logos
|
||||
mkdir -p /data/Recordings
|
||||
mkdir -p /data/recordings
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue