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>);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user