feat: Add hook functionality for task lifecycle

This commit is contained in:
Peifan Li
2025-12-30 22:11:20 -05:00
parent 9c0ab6d450
commit 6f1a1cd12f
13 changed files with 765 additions and 28 deletions

View File

@@ -0,0 +1,192 @@
import { CheckCircle, CloudUpload, Delete, ErrorOutline } from '@mui/icons-material';
import { Alert, Box, Button, CircularProgress, Grid, Paper, Typography } from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Settings } from '../../types';
import ConfirmationModal from '../ConfirmationModal';
interface HookSettingsProps {
settings: Settings;
onChange: (field: keyof Settings, value: any) => void;
}
const API_URL = import.meta.env.VITE_API_URL;
const HookSettings: React.FC<HookSettingsProps> = () => {
const { t } = useLanguage();
const [deleteHookName, setDeleteHookName] = useState<string | null>(null);
const { data: hookStatus, refetch: refetchHooks, isLoading } = useQuery({
queryKey: ['hookStatus'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings/hooks/status`);
return response.data as Record<string, boolean>;
}
});
const uploadMutation = useMutation({
mutationFn: async ({ hookName, file }: { hookName: string; file: File }) => {
const formData = new FormData();
formData.append('file', file);
await axios.post(`${API_URL}/settings/hooks/${hookName}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
},
onSuccess: () => {
refetchHooks();
}
});
const deleteMutation = useMutation({
mutationFn: async (hookName: string) => {
await axios.delete(`${API_URL}/settings/hooks/${hookName}`);
},
onSuccess: () => {
refetchHooks();
setDeleteHookName(null);
}
});
const handleFileUpload = (hookName: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.sh') && !file.name.endsWith('.bash')) {
alert('Only .sh files are allowed');
return;
}
uploadMutation.mutate({ hookName, file });
// Reset input so the same file can be selected again
e.target.value = '';
};
const handleDelete = (hookName: string) => {
setDeleteHookName(hookName);
};
const confirmDelete = () => {
if (deleteHookName) {
deleteMutation.mutate(deleteHookName);
}
};
const hooksConfig = [
{
name: 'task_before_start',
label: t('hookTaskBeforeStart'),
helper: t('hookTaskBeforeStartHelper'),
},
{
name: 'task_success',
label: t('hookTaskSuccess'),
helper: t('hookTaskSuccessHelper'),
},
{
name: 'task_fail',
label: t('hookTaskFail'),
helper: t('hookTaskFailHelper'),
},
{
name: 'task_cancel',
label: t('hookTaskCancel'),
helper: t('hookTaskCancelHelper'),
}
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h6" gutterBottom>{t('taskHooks')}</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t('taskHooksDescription')}
</Typography>
<Alert severity="info" sx={{ mb: 3 }}>
{t('taskHooksWarning')}
</Alert>
{isLoading ? (
<CircularProgress />
) : (
<Grid container spacing={2}>
{hooksConfig.map((hook) => {
const exists = hookStatus?.[hook.name];
return (
<Grid size={{ xs: 12, md: 6 }} key={hook.name}>
<Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" fontWeight="bold">
{hook.label}
</Typography>
{exists ? (
<Alert icon={<CheckCircle fontSize="inherit" />} severity="success" sx={{ py: 0, px: 1 }}>
{t('found') || 'Found'}
</Alert>
) : (
<Alert icon={<ErrorOutline fontSize="inherit" />} severity="warning" sx={{ py: 0, px: 1 }}>
{t('notFound') || 'Not Set'}
</Alert>
)}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, minHeight: 40 }}>
{hook.helper}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
component="label"
size="small"
startIcon={<CloudUpload />}
disabled={uploadMutation.isPending}
>
{uploadMutation.isPending ? 'Up...' : (t('uploadHook') || 'Upload .sh')}
<input
type="file"
hidden
accept=".sh,.bash"
onChange={handleFileUpload(hook.name)}
/>
</Button>
{exists && (
<Button
variant="outlined"
color="error"
size="small"
startIcon={<Delete />}
onClick={() => handleDelete(hook.name)}
disabled={deleteMutation.isPending}
>
{t('delete') || 'Delete'}
</Button>
)}
</Box>
</Paper>
</Grid>
);
})}
</Grid>
)}
</Box>
<ConfirmationModal
isOpen={!!deleteHookName}
onClose={() => setDeleteHookName(null)}
onConfirm={confirmDelete}
title={t('deleteHook') || 'Delete Hook Script'}
message={t('confirmDeleteHook') || 'Are you sure you want to delete this hook script?'}
confirmText={t('delete') || 'Delete'}
cancelText={t('cancel') || 'Cancel'}
isDanger={true}
/>
</Box>
);
};
export default HookSettings;

View File

@@ -22,6 +22,7 @@ import CloudflareSettings from '../components/Settings/CloudflareSettings';
import CookieSettings from '../components/Settings/CookieSettings';
import DatabaseSettings from '../components/Settings/DatabaseSettings';
import DownloadSettings from '../components/Settings/DownloadSettings';
import HookSettings from '../components/Settings/HookSettings';
import InterfaceDisplaySettings from '../components/Settings/InterfaceDisplaySettings';
import SecuritySettings from '../components/Settings/SecuritySettings';
import TagsSettings from '../components/Settings/TagsSettings';
@@ -64,7 +65,8 @@ const SettingsPage: React.FC = () => {
showYoutubeSearch: true,
proxyOnlyYoutube: false,
moveSubtitlesToVideoFolder: false,
moveThumbnailsToVideoFolder: false
moveThumbnailsToVideoFolder: false,
hooks: {}
});
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
@@ -314,10 +316,16 @@ const SettingsPage: React.FC = () => {
{/* 8. Advanced */}
<Grid size={12}>
<CollapsibleSection title={t('advanced')} defaultExpanded={false}>
<AdvancedSettings
debugMode={debugMode}
onDebugModeChange={setDebugMode}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdvancedSettings
debugMode={debugMode}
onDebugModeChange={setDebugMode}
/>
<HookSettings
settings={settings}
onChange={handleChange}
/>
</Box>
</CollapsibleSection>
</Grid>
</>

View File

@@ -85,4 +85,10 @@ export interface Settings {
cloudflaredTunnelEnabled?: boolean;
cloudflaredToken?: string;
pauseOnFocusLoss?: boolean;
hooks?: {
task_before_start?: string;
task_success?: string;
task_fail?: string;
task_cancel?: string;
};
}

View File

@@ -114,6 +114,24 @@ export const en = {
cleanupTempFilesConfirmTitle: "Clean Up Temporary Files?",
cleanupTempFilesConfirmMessage:
"This will permanently delete all .ytdl and .part files in the uploads directory. Make sure there are no active downloads before proceeding.",
// Task Hooks
taskHooks: 'Task Hooks',
taskHooksDescription: 'Execute custom shell commands at specific points in the task lifecycle. Available environment variables: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Warning: Commands run with the server\'s permissions. Use with caution.',
hookTaskBeforeStart: 'Before Task Start',
hookTaskBeforeStartHelper: 'Executes before the download begins.',
hookTaskSuccess: 'Task Success',
hookTaskSuccessHelper: 'Executes after successful download, before cloud upload/deletion (awaits completion).',
hookTaskFail: 'Task Failed',
hookTaskFailHelper: 'Executes when a task fails.',
hookTaskCancel: 'Task Cancelled',
hookTaskCancelHelper: 'Executes when a task is manually cancelled.',
found: 'Found',
notFound: 'Not Set',
deleteHook: 'Delete Hook Script',
confirmDeleteHook: 'Are you sure you want to delete this hook script?',
uploadHook: 'Upload .sh',
cleanupTempFilesActiveDownloads:
"Cannot clean up temporary files while downloads are active. Please wait for all downloads to complete or cancel them first.",
formatFilenamesSuccess: