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

@@ -13,3 +13,4 @@ export const DATA_DIR: string = path.join(ROOT_DIR, "data");
export const VIDEOS_DATA_PATH: string = path.join(DATA_DIR, "videos.json");
export const STATUS_DATA_PATH: string = path.join(DATA_DIR, "status.json");
export const COLLECTIONS_DATA_PATH: string = path.join(DATA_DIR, "collections.json");
export const HOOKS_DIR: string = path.join(DATA_DIR, "hooks");

View File

@@ -0,0 +1,63 @@
import { Request, Response } from "express";
import { ValidationError } from "../errors/DownloadErrors";
import { HookService } from "../services/hookService";
import { successMessage } from "../utils/response";
/**
* Upload hook script
*/
export const uploadHook = async (
req: Request,
res: Response
): Promise<void> => {
const { name } = req.params;
if (!req.file) {
throw new ValidationError("No file uploaded", "file");
}
// Basic validation of hook name
const validHooks = [
"task_before_start",
"task_success",
"task_fail",
"task_cancel",
];
if (!validHooks.includes(name)) {
throw new ValidationError("Invalid hook name", "name");
}
HookService.uploadHook(name, req.file.path);
res.json(successMessage(`Hook ${name} uploaded successfully`));
};
/**
* Delete hook script
*/
export const deleteHook = async (
req: Request,
res: Response
): Promise<void> => {
const { name } = req.params;
const deleted = HookService.deleteHook(name);
if (deleted) {
res.json(successMessage(`Hook ${name} deleted successfully`));
} else {
// If not found, we can still consider it "success" as the desired state is reached,
// or return 404. For idempotency, success is often fine, but let's be explicit.
res.status(404).json({ success: false, message: "Hook not found" });
}
};
/**
* Get hooks status
*/
export const getHookStatus = async (
_req: Request,
res: Response
): Promise<void> => {
const status = HookService.getHookStatus();
res.json(status);
};

View File

@@ -1,14 +1,6 @@
import express from "express";
import multer from "multer";
import os from "os";
import {
deleteLegacyData,
formatFilenames,
getCloudflaredStatus,
getSettings,
migrateData,
updateSettings,
} from "../controllers/settingsController";
import {
checkCookies,
deleteCookies,
@@ -22,10 +14,21 @@ import {
restoreFromLastBackup,
} from "../controllers/databaseBackupController";
import {
getPasswordEnabled,
resetPassword,
verifyPassword,
deleteHook,
getHookStatus,
uploadHook,
} from "../controllers/hookController";
import {
getPasswordEnabled
} from "../controllers/passwordController";
import {
deleteLegacyData,
formatFilenames,
getCloudflaredStatus,
getSettings,
migrateData,
updateSettings,
} from "../controllers/settingsController";
import { asyncHandler } from "../middleware/errorHandler";
const router = express.Router();
@@ -40,8 +43,8 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
// Password routes
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
router.post("/verify-password", asyncHandler(verifyPassword));
router.post("/reset-password", asyncHandler(resetPassword));
// ... existing imports ...
// Cookie routes
router.post(
@@ -52,6 +55,15 @@ router.post(
router.post("/delete-cookies", asyncHandler(deleteCookies));
router.get("/check-cookies", asyncHandler(checkCookies));
// Hook routes
router.post(
"/hooks/:name",
upload.single("file"),
asyncHandler(uploadHook)
);
router.delete("/hooks/:name", asyncHandler(deleteHook));
router.get("/hooks/status", asyncHandler(getHookStatus));
// Database backup routes
router.get("/export-database", asyncHandler(exportDatabase));
router.post(

View File

@@ -0,0 +1,102 @@
import path from "path";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Define mocks
const mocks = vi.hoisted(() => ({
exec: vi.fn(),
existsSync: vi.fn(),
chmodSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock("child_process", () => ({
default: {
exec: mocks.exec,
},
exec: mocks.exec,
}));
vi.mock("fs", () => ({
default: {
existsSync: mocks.existsSync,
chmodSync: mocks.chmodSync,
mkdirSync: mocks.mkdirSync,
},
existsSync: mocks.existsSync,
chmodSync: mocks.chmodSync,
mkdirSync: mocks.mkdirSync,
}));
import { HOOKS_DIR } from "../../config/paths";
import { logger } from "../../utils/logger";
import { HookService } from "../hookService";
vi.mock("../../utils/logger");
describe("HookService", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default exec implementation
mocks.exec.mockImplementation((cmd: string, options: any, callback: any) => {
if (callback) {
callback(null, { stdout: "ok", stderr: "" });
}
return { stdout: "ok", stderr: "" };
});
});
it("should execute configured hook", async () => {
// Mock file existence
mocks.existsSync.mockImplementation((p: string) => {
return p === path.join(HOOKS_DIR, "task_start.sh");
});
await HookService.executeHook("task_start", {
taskId: "123",
taskTitle: "Test Task",
status: "start",
});
const expectedPath = path.join(HOOKS_DIR, "task_start.sh");
expect(mocks.exec).toHaveBeenCalledTimes(1);
// Check command
expect(mocks.exec.mock.calls[0][0]).toBe(`bash "${expectedPath}"`);
// Check env vars
const env = mocks.exec.mock.calls[0][1]?.env;
expect(env).toBeDefined();
expect(env?.MYTUBE_TASK_ID).toBe("123");
expect(env?.MYTUBE_TASK_TITLE).toBe("Test Task");
expect(env?.MYTUBE_TASK_STATUS).toBe("start");
});
it("should not execute if hook file does not exist", async () => {
mocks.existsSync.mockReturnValue(false);
await HookService.executeHook("task_start", {
taskId: "123",
taskTitle: "Test Task",
status: "start",
});
expect(mocks.exec).not.toHaveBeenCalled();
});
it("should handle execution errors gracefully", async () => {
mocks.existsSync.mockReturnValue(true);
mocks.exec.mockImplementation((cmd: string, options: any, callback: any) => {
throw new Error("Command failed");
});
// Should not throw
await HookService.executeHook("task_fail", {
taskId: "123",
taskTitle: "Test Task",
status: "fail",
});
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,7 @@ import { extractSourceVideoId } from "../utils/helpers";
import { logger } from "../utils/logger";
import { CloudStorageService } from "./CloudStorageService";
import { createDownloadTask } from "./downloadService";
import { HookService } from "./hookService";
import * as storageService from "./storageService";
interface DownloadTask {
@@ -179,6 +180,14 @@ class DownloadManager {
sourceUrl: task.sourceUrl,
});
// Execute hook
HookService.executeHook("task_cancel", {
taskId: task.id,
taskTitle: task.title,
sourceUrl: task.sourceUrl,
status: "cancel",
});
// Clean up internal state
this.activeTasks.delete(id);
this.activeDownloads--;
@@ -259,6 +268,15 @@ class DownloadManager {
try {
console.log(`Starting download: ${task.title} (${task.id})`);
// Execute hook
await HookService.executeHook("task_before_start", {
taskId: task.id,
taskTitle: task.title,
sourceUrl: task.sourceUrl,
status: "start",
});
const result = await task.downloadFn((cancel) => {
task.cancelFn = cancel;
});
@@ -345,6 +363,18 @@ class DownloadManager {
}
}
// Execute hook - Await this so user script can process local file before cloud upload/delete
await HookService.executeHook("task_success", {
taskId: task.id,
taskTitle: finalTitle || task.title,
sourceUrl: task.sourceUrl,
status: "success",
videoPath: videoData.videoPath,
thumbnailPath: videoData.thumbnailPath,
});
// Trigger Cloud Upload (Async, don't await to block queue processing?)
// Actually, we might want to await it if we want to ensure it's done before resolving,
// but that would block the download queue.
@@ -379,6 +409,15 @@ class DownloadManager {
});
}
// Execute hook
HookService.executeHook("task_fail", {
taskId: task.id,
taskTitle: task.title,
sourceUrl: task.sourceUrl,
status: "fail",
error: error instanceof Error ? error.message : String(error),
});
task.reject(error);
} finally {
// Only clean up if the task wasn't already cleaned up by cancelDownload

View File

@@ -0,0 +1,117 @@
import child_process from "child_process";
import fs from "fs";
import path from "path";
import util from "util";
import { HOOKS_DIR } from "../config/paths";
import { logger } from "../utils/logger";
export interface HookContext {
taskId: string;
taskTitle: string;
sourceUrl?: string;
status: "start" | "success" | "fail" | "cancel";
videoPath?: string;
thumbnailPath?: string;
error?: string;
}
const execPromise = util.promisify(child_process.exec);
export class HookService {
/**
* Initialize hooks directory
*/
static initialize(): void {
if (!fs.existsSync(HOOKS_DIR)) {
fs.mkdirSync(HOOKS_DIR, { recursive: true });
}
}
/**
* Execute a hook script if it exists
*/
static async executeHook(
eventName: string,
context: Record<string, string | undefined>
): Promise<void> {
try {
const hookPath = path.join(HOOKS_DIR, `${eventName}.sh`);
if (!fs.existsSync(hookPath)) {
return;
}
logger.info(
`[HookService] Executing hook: ${eventName} (${hookPath})`
);
// Ensure the script is executable
fs.chmodSync(hookPath, "755");
const env: Record<string, string> = { ...process.env } as Record<string, string>;
if (context.taskId) env.MYTUBE_TASK_ID = context.taskId;
if (context.taskTitle) env.MYTUBE_TASK_TITLE = context.taskTitle;
if (context.sourceUrl) env.MYTUBE_SOURCE_URL = context.sourceUrl;
if (context.status) env.MYTUBE_TASK_STATUS = context.status;
if (context.videoPath) env.MYTUBE_VIDEO_PATH = context.videoPath;
if (context.thumbnailPath) env.MYTUBE_THUMBNAIL_PATH = context.thumbnailPath;
if (context.error) env.MYTUBE_ERROR = context.error;
await execPromise(`bash "${hookPath}"`, { env });
logger.info(`[HookService] Hook ${eventName} executed successfully.`);
} catch (error: any) {
logger.error(
`[HookService] Error executing hook ${eventName}: ${error.message}`
);
// We log but don't re-throw to prevent hook failures from stopping the task
}
}
/**
* Upload a hook script
*/
static uploadHook(hookName: string, filePath: string): void {
this.initialize();
const destPath = path.join(HOOKS_DIR, `${hookName}.sh`);
// Move and rename the uploaded file
fs.copyFileSync(filePath, destPath);
// Cleanup original upload
fs.unlinkSync(filePath);
// Make executable
fs.chmodSync(destPath, "755");
logger.info(`[HookService] Uploaded hook script: ${destPath}`);
}
/**
* Delete a hook script
*/
static deleteHook(hookName: string): boolean {
const hookPath = path.join(HOOKS_DIR, `${hookName}.sh`);
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
logger.info(`[HookService] Deleted hook script: ${hookPath}`);
return true;
}
return false;
}
/**
* Get hook status
*/
static getHookStatus(): Record<string, boolean> {
this.initialize();
const hooks = [
"task_before_start",
"task_success",
"task_fail",
"task_cancel",
];
return hooks.reduce((acc, hook) => {
acc[hook] = fs.existsSync(path.join(HOOKS_DIR, `${hook}.sh`));
return acc;
}, {} as Record<string, boolean>);
}
}

View File

@@ -0,0 +1,68 @@
# Task Hooks Guide
MyTube allows you to execute custom shell scripts at various stages of a download task's lifecycle. This feature is powerful for integrating MyTube with other systems, performing post-processing on downloaded files, or sending notifications.
## available Hooks
You can configure commands for the following events:
| Hook Name | Trigger Point | Description |
| :--- | :--- | :--- |
| **Before Task Start** (`task_before_start`) | Before download begins | Executed immediately before the download process starts. Useful for setup or validation. |
| **Task Success** (`task_success`) | After successful download | Executed after the file is successfully downloaded / merged, but **before** it is uploaded to cloud storage (if enabled) or deleted. This is the ideal place for file processing. |
| **Task Failed** (`task_fail`) | On task failure | Executed if the download fails for any reason. |
| **Task Cancelled** (`task_cancel`) | On task cancellation | Executed when a user manually cancels a running task. |
## Environment Variables
When a hook command is executed, the following environment variables are injected into the shell environment, providing context about the task:
| Variable | Description | Example |
| :--- | :--- | :--- |
| `MYTUBE_TASK_ID` | The unique identifier of the task | `335e98f0-15cb-46a4-846d-9d4351368923` |
| `MYTUBE_TASK_TITLE` | The title of the video/task | `Awesome Video Title` |
| `MYTUBE_SOURCE_URL` | The original URL of the video | `https://www.youtube.com/watch?v=...` |
| `MYTUBE_TASK_STATUS` | The current status of the task | `success`, `fail`, `cancelled` |
| `MYTUBE_VIDEO_PATH` | Absolute path to the downloaded video file | `/app/downloads/video.mp4` |
| `MYTUBE_THUMBNAIL_PATH` | Absolute path to the thumbnail file | `/app/downloads/video.jpg` |
| `MYTUBE_ERROR` | Error message (only for `task_fail`) | `Network timeout` |
## Configuration
You can configure hooks in the web interface:
1. Go to **Settings**.
2. Scroll down to **Advanced Settings**.
3. Find the **Task Hooks** section.
4. Enter your shell commands for the desired events.
5. Click **Save**.
## Examples
### 1. Simple Logging
Log every successful download to a file.
**Hook:** `task_success`
```bash
echo "[$(date)] Downloaded: $MYTUBE_TASK_TITLE" >> /path/to/mytube_downloads.log
```
### 2. Send Notification (e.g., ntfy.sh)
Send a notification when a task fails.
**Hook:** `task_fail`
```bash
curl -d "MyTube Download Failed: $MYTUBE_TASK_TITLE - $MYTUBE_ERROR" https://ntfy.sh/my_topic
```
### 3. File Post-Processing
Run a script to process the file (e.g., move it or re-encode it).
**Hook:** `task_success`
```bash
/path/to/my_processing_script.sh "$MYTUBE_VIDEO_PATH"
```
## Security Warning
> [!WARNING]
> Hook commands are executed with the same permissions as the MyTube backend server.
> - Be careful when using commands that modify or delete files.
> - Do not copy-paste scripts from untrusted sources.
> - Ensure your scripts handle errors gracefully.

43
documents/sample_hook.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Sample Hook Script for MyTube
# Usage: Configure this script in MyTube Settings -> Advanced -> Task Hooks
# Example: Set "Task Success" to: bash /path/to/documents/sample_hook.sh
# Output log file
LOG_FILE="/tmp/mytube_hook_sample.log"
echo "==================================================" >> "$LOG_FILE"
echo "MyTube Hook Triggered at $(date)" >> "$LOG_FILE"
echo "==================================================" >> "$LOG_FILE"
# Log Basic Task Information
echo "Task ID: $MYTUBE_TASK_ID" >> "$LOG_FILE"
echo "Task Title: $MYTUBE_TASK_TITLE" >> "$LOG_FILE"
echo "Task Status: $MYTUBE_TASK_STATUS" >> "$LOG_FILE"
echo "Source URL: $MYTUBE_SOURCE_URL" >> "$LOG_FILE"
# Handle specific states
if [ "$MYTUBE_TASK_STATUS" == "success" ]; then
echo "✅ Download Completed Successfully" >> "$LOG_FILE"
echo " Video Path: $MYTUBE_VIDEO_PATH" >> "$LOG_FILE"
echo " Thumbnail Path: $MYTUBE_THUMBNAIL_PATH" >> "$LOG_FILE"
# Example: Check file existence and size
if [ -f "$MYTUBE_VIDEO_PATH" ]; then
SIZE=$(ls -lh "$MYTUBE_VIDEO_PATH" | awk '{print $5}')
echo " File Size: $SIZE" >> "$LOG_FILE"
echo " File exists and is ready for post-processing." >> "$LOG_FILE"
else
echo " ⚠️ Warning: Video file not found at path." >> "$LOG_FILE"
fi
elif [ "$MYTUBE_TASK_STATUS" == "fail" ]; then
echo "❌ Download Failed" >> "$LOG_FILE"
echo " Error: $MYTUBE_ERROR" >> "$LOG_FILE"
elif [ "$MYTUBE_TASK_STATUS" == "cancelled" ]; then
echo "⚠️ Download Cancelled by User" >> "$LOG_FILE"
fi
echo "--------------------------------------------------" >> "$LOG_FILE"

View File

@@ -0,0 +1,68 @@
# 任务钩子使用指南 (Task Hooks Guide)
MyTube允许您在下载任务生命周期的不同阶段执行自定义Shell脚本。此功能对于与其他系统集成、对下载的文件进行后处理或发送通知非常有用。
## 可用钩子 (Available Hooks)
您可以为以下事件配置命令:
| 钩子名称 | 触发时机 | 描述 |
| :--- | :--- | :--- |
| **任务开始前** (`task_before_start`) | 下载开始前 | 在下载过程开始之前立即执行。适用于设置或验证。 |
| **任务成功** (`task_success`) | 下载成功后 | 在文件成功下载/合并后,但在上传到云存储(如果启用)或删除之前执行。这是文件处理的理想位置。 |
| **任务失败** (`task_fail`) | 任务失败时 | 如果下载因任何原因失败,则执行此钩子。 |
| **任务取消** (`task_cancel`) | 任务取消时 | 当用户手动取消正在运行的任务时执行。 |
## 环境变量 (Environment Variables)
当钩子命令执行时以下环境变量将被注入到Shell环境提供有关任务的上下文
| 变量 | 描述 | 示例 |
| :--- | :--- | :--- |
| `MYTUBE_TASK_ID` | 任务的唯一标识符 | `335e98f0-15cb-46a4-846d-9d4351368923` |
| `MYTUBE_TASK_TITLE` | 视频/任务的标题 | `Awesome Video Title` |
| `MYTUBE_SOURCE_URL` | 视频的原始URL | `https://www.youtube.com/watch?v=...` |
| `MYTUBE_TASK_STATUS` | 任务的当前状态 | `success`, `fail`, `cancelled` |
| `MYTUBE_VIDEO_PATH` | 下载视频文件的绝对路径 | `/app/downloads/video.mp4` |
| `MYTUBE_THUMBNAIL_PATH` | 缩略图文件的绝对路径 | `/app/downloads/video.jpg` |
| `MYTUBE_ERROR` | 错误消息(仅限 `task_fail` | `Network timeout` |
## 配置 (Configuration)
您可以在网页界面中配置钩子:
1. 转到 **设置 (Settings)**
2. 向下滚动到 **高级设置 (Advanced Settings)**
3. 找到 **任务钩子 (Task Hooks)** 部分。
4. 输入您想要为特定事件执行的Shell命令。
5. 点击 **保存 (Save)**
## 示例 (Examples)
### 1. 简单日志记录
将每个成功的下载记录到文件中。
**钩子:** `task_success`
```bash
echo "[$(date)] Downloaded: $MYTUBE_TASK_TITLE" >> /path/to/mytube_downloads.log
```
### 2. 发送通知 (例如 ntfy.sh)
当任务失败时发送通知。
**钩子:** `task_fail`
```bash
curl -d "MyTube Download Failed: $MYTUBE_TASK_TITLE - $MYTUBE_ERROR" https://ntfy.sh/my_topic
```
### 3. 文件后处理
运行脚本处理文件(例如,移动文件或重新编码)。
**钩子:** `task_success`
```bash
/path/to/my_processing_script.sh "$MYTUBE_VIDEO_PATH"
```
## 安全警告 (Security Warning)
> [!WARNING]
> 钩子命令以与MyTube后端服务器相同的权限执行。
> - 使用修改或删除文件的命令时请务必小心。
> - 请勿复制粘贴来自不可信来源的脚本。
> - 确保您的脚本能够优雅地处理错误。

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}>
<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: