From 6f1a1cd12f506e3e84eadff56a3c78c489b4459c Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Tue, 30 Dec 2025 22:11:20 -0500 Subject: [PATCH] feat: Add hook functionality for task lifecycle --- backend/src/config/paths.ts | 1 + backend/src/controllers/hookController.ts | 63 ++++++ backend/src/routes/settingsRoutes.ts | 54 +++-- .../services/__tests__/hookService.test.ts | 102 ++++++++++ backend/src/services/downloadManager.ts | 43 +++- backend/src/services/hookService.ts | 117 +++++++++++ documents/en/hooks-guide.md | 68 +++++++ documents/sample_hook.sh | 43 ++++ documents/zh/hooks-guide.md | 68 +++++++ .../src/components/Settings/HookSettings.tsx | 192 ++++++++++++++++++ frontend/src/pages/SettingsPage.tsx | 18 +- frontend/src/types.ts | 6 + frontend/src/utils/locales/en.ts | 18 ++ 13 files changed, 765 insertions(+), 28 deletions(-) create mode 100644 backend/src/controllers/hookController.ts create mode 100644 backend/src/services/__tests__/hookService.test.ts create mode 100644 backend/src/services/hookService.ts create mode 100644 documents/en/hooks-guide.md create mode 100755 documents/sample_hook.sh create mode 100644 documents/zh/hooks-guide.md create mode 100644 frontend/src/components/Settings/HookSettings.tsx diff --git a/backend/src/config/paths.ts b/backend/src/config/paths.ts index 26d834f..07388f6 100644 --- a/backend/src/config/paths.ts +++ b/backend/src/config/paths.ts @@ -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"); diff --git a/backend/src/controllers/hookController.ts b/backend/src/controllers/hookController.ts new file mode 100644 index 0000000..50c786e --- /dev/null +++ b/backend/src/controllers/hookController.ts @@ -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 => { + 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 => { + 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 => { + const status = HookService.getHookStatus(); + res.json(status); +}; diff --git a/backend/src/routes/settingsRoutes.ts b/backend/src/routes/settingsRoutes.ts index a9d7110..69c660b 100644 --- a/backend/src/routes/settingsRoutes.ts +++ b/backend/src/routes/settingsRoutes.ts @@ -2,30 +2,33 @@ 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, - uploadCookies, + checkCookies, + deleteCookies, + uploadCookies, } from "../controllers/cookieController"; import { - cleanupBackupDatabases, - exportDatabase, - getLastBackupInfo, - importDatabase, - restoreFromLastBackup, + cleanupBackupDatabases, + exportDatabase, + getLastBackupInfo, + importDatabase, + 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( diff --git a/backend/src/services/__tests__/hookService.test.ts b/backend/src/services/__tests__/hookService.test.ts new file mode 100644 index 0000000..39863c4 --- /dev/null +++ b/backend/src/services/__tests__/hookService.test.ts @@ -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(); + }); +}); diff --git a/backend/src/services/downloadManager.ts b/backend/src/services/downloadManager.ts index bd2cc22..888f45a 100644 --- a/backend/src/services/downloadManager.ts +++ b/backend/src/services/downloadManager.ts @@ -1,11 +1,12 @@ import { - DownloadCancelledError, - isCancelledError, + DownloadCancelledError, + isCancelledError, } from "../errors/DownloadErrors"; 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 diff --git a/backend/src/services/hookService.ts b/backend/src/services/hookService.ts new file mode 100644 index 0000000..0967fcf --- /dev/null +++ b/backend/src/services/hookService.ts @@ -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 + ): Promise { + 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 = { ...process.env } as Record; + + 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 { + 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); + } +} diff --git a/documents/en/hooks-guide.md b/documents/en/hooks-guide.md new file mode 100644 index 0000000..8ea6fa4 --- /dev/null +++ b/documents/en/hooks-guide.md @@ -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. diff --git a/documents/sample_hook.sh b/documents/sample_hook.sh new file mode 100755 index 0000000..a0031b8 --- /dev/null +++ b/documents/sample_hook.sh @@ -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" diff --git a/documents/zh/hooks-guide.md b/documents/zh/hooks-guide.md new file mode 100644 index 0000000..471b3a2 --- /dev/null +++ b/documents/zh/hooks-guide.md @@ -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后端服务器相同的权限执行。 +> - 使用修改或删除文件的命令时请务必小心。 +> - 请勿复制粘贴来自不可信来源的脚本。 +> - 确保您的脚本能够优雅地处理错误。 diff --git a/frontend/src/components/Settings/HookSettings.tsx b/frontend/src/components/Settings/HookSettings.tsx new file mode 100644 index 0000000..00854df --- /dev/null +++ b/frontend/src/components/Settings/HookSettings.tsx @@ -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 = () => { + const { t } = useLanguage(); + const [deleteHookName, setDeleteHookName] = useState(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; + } + }); + + 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) => { + 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 ( + + + {t('taskHooks')} + + {t('taskHooksDescription')} + + + + {t('taskHooksWarning')} + + + {isLoading ? ( + + ) : ( + + {hooksConfig.map((hook) => { + const exists = hookStatus?.[hook.name]; + return ( + + + + + {hook.label} + + {exists ? ( + } severity="success" sx={{ py: 0, px: 1 }}> + {t('found') || 'Found'} + + ) : ( + } severity="warning" sx={{ py: 0, px: 1 }}> + {t('notFound') || 'Not Set'} + + )} + + + + {hook.helper} + + + + + + {exists && ( + + )} + + + + ); + })} + + )} + + + 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} + /> + + ); +}; + +export default HookSettings; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index d75cd0a..d9d5c07 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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 */} - + + + + diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cddc674..9c18fa2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; + }; } diff --git a/frontend/src/utils/locales/en.ts b/frontend/src/utils/locales/en.ts index 58d52f8..a23c8f0 100644 --- a/frontend/src/utils/locales/en.ts +++ b/frontend/src/utils/locales/en.ts @@ -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: