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

@@ -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(

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

@@ -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

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>);
}
}