feat: Add hook functionality for task lifecycle
This commit is contained in:
@@ -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");
|
||||
|
||||
63
backend/src/controllers/hookController.ts
Normal file
63
backend/src/controllers/hookController.ts
Normal 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);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
102
backend/src/services/__tests__/hookService.test.ts
Normal file
102
backend/src/services/__tests__/hookService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
117
backend/src/services/hookService.ts
Normal file
117
backend/src/services/hookService.ts
Normal 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>);
|
||||
}
|
||||
}
|
||||
68
documents/en/hooks-guide.md
Normal file
68
documents/en/hooks-guide.md
Normal 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
43
documents/sample_hook.sh
Executable 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"
|
||||
68
documents/zh/hooks-guide.md
Normal file
68
documents/zh/hooks-guide.md
Normal 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后端服务器相同的权限执行。
|
||||
> - 使用修改或删除文件的命令时请务必小心。
|
||||
> - 请勿复制粘贴来自不可信来源的脚本。
|
||||
> - 确保您的脚本能够优雅地处理错误。
|
||||
192
frontend/src/components/Settings/HookSettings.tsx
Normal file
192
frontend/src/components/Settings/HookSettings.tsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user